diff --git a/docs/roadmap.md b/docs/roadmap.md
index b312772..89a95ed 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -257,28 +257,34 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [x] Template instantiation for reuse
- [x] Unit tests for multi-step routines with transitions (45 tests)
-- [ ] **Routine editor (`components/RoutineEditor.tsx`)**
- - [ ] Add/remove/reorder steps (drag-and-drop)
- - [ ] Per-step: label, duration, urgency, notes
- - [ ] Routine templates: Morning (5 steps), Evening Wind-down (4 steps), Cooking Prep (3 steps)
- - [ ] Save routine as template for reuse
- - [ ] Total duration display (auto-calculated)
+- [x] **Routine editor (`components/RoutineEditor.tsx`)**
+ - [x] Add/remove/reorder steps (arrow buttons)
+ - [x] Per-step: label, duration, transition type, notes
+ - [x] Built-in templates: Morning (5), Workout (4), Cooking (4), Wind-Down (4)
+ - [x] Save routine as reusable template (checkbox toggle)
+ - [x] Total duration display (auto-calculated, includes transitions)
- [ ] Preview timeline of routine
-- [ ] **Routine runner (`components/RoutineRunner.tsx`)**
- - [ ] Full-screen routine execution view
- - [ ] Current step highlighted with countdown ring
- - [ ] Next step preview
- - [ ] Progress bar showing overall routine progress
- - [ ] Step transition animation (gentle slide)
- - [ ] "Done early" button to advance
+- [x] **Routine runner (`components/RoutineRunner.tsx`)**
+ - [x] Full-screen routine execution view with countdown ring
+ - [x] Current step highlighted with countdown ring + label
+ - [x] Next step preview with duration
+ - [x] Progress bar showing overall routine progress
+ - [x] Step dot indicators (completed/skipped/active/pending)
+ - [x] "Done early" button to advance + Skip + Pause/Resume
+ - [x] Completion celebration with step summary
+ - [x] Routine store (`lib/routine-store.ts`) with Zustand + localStorage persistence
+ - [x] Auto-advance steps via tickRoutines() on rAF loop
+ - [x] Routines page (`app/routines/`) with template browser, runner, history
-- [ ] **Linked timers (`lib/linked-timers.ts`)**
- - [ ] "When timer A ends, start timer B" relationship
+- [x] **Linked timers (`lib/linked-timers.ts`)**
+ - [x] "When timer A ends, start timer B" chain relationships
+ - [x] Chain building, append, remove with automatic relinking
+ - [x] Linked timer presets: Pasta, Laundry, Meeting Prep
+ - [x] Cancel chain helpers (hasDownstreamTimers, getDownstreamTimerIds)
+ - [x] Chain queries: getNextInChain, findChainForTimer, getChainPosition
- [ ] Chain visualization in timeline
- - [ ] Linked timer presets: Pasta (boil water → add pasta → start sauce)
- - [ ] Cancel chain option (cancel one, option to cancel all linked)
- - [ ] Unit tests for chain execution
+ - [x] Unit tests (27 tests)
- [x] **Natural language input (`lib/nl-parser.ts` + CreateTimerModal integration)** ([8fe5e8e](https://github.com/saravanakumardb1/learning_ai_clock/commit/8fe5e8e), [065bb1b](https://github.com/saravanakumardb1/learning_ai_clock/commit/065bb1b))
- [x] Regex-based parser (no chrono-node dependency needed)
diff --git a/web/src/app/routines/page.tsx b/web/src/app/routines/page.tsx
new file mode 100644
index 0000000..6c7619a
--- /dev/null
+++ b/web/src/app/routines/page.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { useRoutineStore } from '@/lib/routine-store';
+import { RoutineEditor } from '@/components/RoutineEditor';
+import { RoutineRunner } from '@/components/RoutineRunner';
+import type { TransitionType } from '@/lib/routines';
+import { ArrowLeft, Plus, Play, Trash2, ListChecks, Clock, Copy } from 'lucide-react';
+
+export default function RoutinesPage() {
+ const { routines, templates, addRoutine, addTemplate, removeRoutine, removeTemplate, start, startFromTemplate } = useRoutineStore();
+ const [mounted, setMounted] = useState(false);
+ const [showEditor, setShowEditor] = useState(false);
+
+ useEffect(() => { setMounted(true); }, []);
+ if (!mounted) return null;
+
+ const activeRoutine = routines.find((r) => r.status === 'active' || r.status === 'paused');
+ const readyRoutines = routines.filter((r) => r.status === 'ready');
+ const pastRoutines = routines.filter((r) => r.status === 'completed' || r.status === 'cancelled').slice(-10).reverse();
+
+ const handleSave = (name: string, description: string, steps: { label: string; durationMinutes: number; transition: TransitionType; customTransitionMinutes: number; notes: string }[], asTemplate: boolean) => {
+ const params = {
+ name,
+ description: description || undefined,
+ steps: steps.map((s) => ({
+ label: s.label,
+ durationMinutes: s.durationMinutes,
+ transition: s.transition as TransitionType,
+ customTransitionMinutes: s.transition === 'custom' ? s.customTransitionMinutes : undefined,
+ notes: s.notes || undefined,
+ })),
+ isTemplate: asTemplate,
+ };
+ if (asTemplate) {
+ addTemplate(params);
+ } else {
+ addRoutine(params);
+ }
+ };
+
+ return (
+
+
+
+
Back to Dashboard
+
+
+
+
Routines
+
+
+
+ {/* Active routine runner */}
+ {activeRoutine && (
+
+ )}
+
+ {/* Templates */}
+
+
+ Templates ({templates.length})
+
+ {templates.length > 0 ? (
+
+ {templates.map((tmpl) => (
+
+
+
{tmpl.name}
+ {tmpl.description && (
+
{tmpl.description}
+ )}
+
+ {tmpl.steps.length} steps
+ {tmpl.totalDurationMinutes}m
+
+
+
+ {!tmpl.isTemplate || tmpl.createdAt > Date.now() - 1000 ? (
+
+ ) : null}
+
+ ))}
+
+ ) : (
+
No templates yet. Create one above.
+ )}
+
+
+ {/* Ready routines */}
+ {readyRoutines.length > 0 && (
+
+
+ Ready ({readyRoutines.length})
+
+
+ {readyRoutines.map((r) => (
+
+
+
{r.name}
+
+ {r.steps.length} steps
+ {r.totalDurationMinutes}m
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Past routines */}
+ {pastRoutines.length > 0 && (
+
+
+ Recent ({pastRoutines.length})
+
+
+ {pastRoutines.map((r) => (
+
+
+
+ ))}
+
+
+ )}
+
+
setShowEditor(false)} onSave={handleSave} />
+
+
+ );
+}
diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx
index 02c3ab3..f49a66c 100644
--- a/web/src/components/Dashboard.tsx
+++ b/web/src/components/Dashboard.tsx
@@ -11,7 +11,7 @@ import { CreateTimerModal } from './CreateTimerModal';
import { AlarmOverlay } from './AlarmOverlay';
import { requestNotificationPermission } from '@/lib/notifications';
import { formatTime, formatDate } from '@/lib/format';
-import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye, BarChart3 } from 'lucide-react';
+import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye, BarChart3, ListChecks } from 'lucide-react';
import { BUILT_IN_CATEGORIES, matchesCategory } from '@/lib/categories';
import Link from 'next/link';
import { FeedbackButton } from './FeedbackButton';
@@ -158,6 +158,14 @@ export function Dashboard() {
>
+
+
+
void;
+ onSave: (name: string, description: string, steps: StepDraft[], asTemplate: boolean) => void;
+ initialName?: string;
+ initialDescription?: string;
+ initialSteps?: StepDraft[];
+}
+
+let stepCounter = 0;
+function newStepDraft(): StepDraft {
+ return {
+ id: `draft-${++stepCounter}-${Date.now()}`,
+ label: '',
+ durationMinutes: 10,
+ transition: 'immediate',
+ customTransitionMinutes: 2,
+ notes: '',
+ };
+}
+
+export function RoutineEditor({
+ isOpen,
+ onClose,
+ onSave,
+ initialName = '',
+ initialDescription = '',
+ initialSteps,
+}: RoutineEditorProps) {
+ const [name, setName] = useState(initialName);
+ const [description, setDescription] = useState(initialDescription);
+ const [steps, setSteps] = useState(initialSteps ?? [newStepDraft(), newStepDraft()]);
+ const [saveAsTemplate, setSaveAsTemplate] = useState(false);
+
+ if (!isOpen) return null;
+
+ const addStep = () => setSteps([...steps, newStepDraft()]);
+
+ const removeStep = (id: string) => {
+ if (steps.length <= 1) return;
+ setSteps(steps.filter((s) => s.id !== id));
+ };
+
+ const updateStep = (id: string, field: keyof StepDraft, value: string | number) => {
+ setSteps(steps.map((s) => (s.id === id ? { ...s, [field]: value } : s)));
+ };
+
+ const moveStep = (fromIdx: number, toIdx: number) => {
+ if (toIdx < 0 || toIdx >= steps.length) return;
+ const next = [...steps];
+ const [moved] = next.splice(fromIdx, 1);
+ next.splice(toIdx, 0, moved);
+ setSteps(next);
+ };
+
+ const totalMinutes = calculateTotalDuration(
+ steps.map((s) => createRoutineStep({
+ label: s.label || 'Step',
+ durationMinutes: s.durationMinutes,
+ transition: s.transition,
+ customTransitionMinutes: s.customTransitionMinutes,
+ }))
+ );
+
+ const canSave = name.trim() && steps.length > 0 && steps.every((s) => s.label.trim() && s.durationMinutes > 0);
+
+ const handleSave = () => {
+ if (!canSave) return;
+ onSave(name.trim(), description.trim(), steps, saveAsTemplate);
+ onClose();
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+ {initialName ? 'Edit Routine' : 'New Routine'}
+
+
+
+
+ {/* Body */}
+
+ {/* Name + description */}
+
+
+ setName(e.target.value)}
+ placeholder="Morning Routine"
+ className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
+ style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-primary)' }}
+ />
+
+
+
+ setDescription(e.target.value)}
+ placeholder="Start your day with intention"
+ className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
+ style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-primary)' }}
+ />
+
+
+ {/* Steps */}
+
+
+
+
+ {totalMinutes} min total
+
+
+
+
+ {steps.map((step, idx) => (
+
+
+ {/* Reorder buttons */}
+
+
+
+
+
+
+ {/* Step number + label */}
+
+ {idx + 1}
+ updateStep(step.id, 'label', e.target.value)}
+ placeholder="Step name"
+ className="flex-1 px-2 py-1 rounded-md border text-sm focus:outline-none"
+ style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-primary)' }}
+ />
+
+
+ {/* Duration + transition */}
+
+
+ updateStep(step.id, 'durationMinutes', Math.max(1, parseInt(e.target.value) || 1))}
+ className="w-14 px-2 py-1 rounded-md border text-xs text-center focus:outline-none"
+ style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-primary)' }}
+ />
+ min
+
+
+ {idx < steps.length - 1 && (
+
+ )}
+
+ {step.transition === 'custom' && idx < steps.length - 1 && (
+
+ updateStep(step.id, 'customTransitionMinutes', Math.max(1, parseInt(e.target.value) || 1))}
+ className="w-12 px-2 py-1 rounded-md border text-xs text-center focus:outline-none"
+ style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-primary)' }}
+ />
+ min
+
+ )}
+
+
+ {/* Notes */}
+
updateStep(step.id, 'notes', e.target.value)}
+ placeholder="Notes (optional)"
+ className="w-full ml-7 px-2 py-1 rounded-md border text-xs focus:outline-none"
+ style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-tertiary)' }}
+ />
+
+
+ {/* Delete */}
+
+
+
+ ))}
+
+
+
+
+
+ {/* Save as template toggle */}
+
+
+
+ {/* Footer */}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/RoutineRunner.tsx b/web/src/components/RoutineRunner.tsx
new file mode 100644
index 0000000..8a217bc
--- /dev/null
+++ b/web/src/components/RoutineRunner.tsx
@@ -0,0 +1,246 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRoutineStore } from '@/lib/routine-store';
+import { useTimerStore } from '@/lib/store';
+import type { Routine } from '@/lib/routines';
+import {
+ getCurrentStep,
+ getNextStep,
+ getRoutineProgress,
+ getRemainingStepMs,
+ getCompletedStepCount,
+ getSkippedStepCount,
+} from '@/lib/routines';
+import { CountdownRing } from './CountdownRing';
+import { formatDuration } from '@/lib/format';
+import {
+ Play, Pause, SkipForward, X, CheckCircle2, ListChecks, Trophy,
+} from 'lucide-react';
+
+interface RoutineRunnerProps {
+ routine: Routine;
+}
+
+export function RoutineRunner({ routine }: RoutineRunnerProps) {
+ const now = useTimerStore((s) => s.now);
+ const { pause, resume, completeStep, skipStep, cancel } = useRoutineStore();
+
+ // Tick routines on each frame
+ useEffect(() => {
+ useRoutineStore.getState().tickRoutines(now);
+ }, [now]);
+
+ const currentStep = getCurrentStep(routine);
+ const nextStep = getNextStep(routine);
+ const progress = getRoutineProgress(routine);
+ const remainingMs = getRemainingStepMs(routine, now);
+ const stepDurationMs = currentStep ? currentStep.durationMinutes * 60_000 : 1;
+ const stepProgress = currentStep ? Math.max(0, remainingMs / stepDurationMs) : 0;
+ const isPaused = routine.status === 'paused';
+ const isCompleted = routine.status === 'completed';
+ const isCancelled = routine.status === 'cancelled';
+
+ // Completed celebration
+ if (isCompleted || isCancelled) {
+ const completed = getCompletedStepCount(routine);
+ const skipped = getSkippedStepCount(routine);
+ return (
+
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isCompleted ? 'Routine Complete!' : 'Routine Cancelled'}
+
+
{routine.name}
+
+ {completed} completed · {skipped} skipped · {routine.steps.length} total steps
+
+ {/* Step summary */}
+
+ {routine.steps.map((step) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (!currentStep) return null;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {routine.name}
+
+
+
+ Step {routine.currentStepIndex + 1} of {routine.steps.length}
+
+
+
+ {/* Overall progress bar */}
+
+
+ {/* Step dots */}
+
+ {routine.steps.map((step, idx) => (
+
+ ))}
+
+
+ {/* Countdown ring */}
+
+
+
+
+ {formatDuration(remainingMs)}
+
+
+ {currentStep.label}
+
+
+
+
+
+ {/* Paused indicator */}
+ {isPaused && (
+
+ Paused
+
+ )}
+
+ {/* Current step notes */}
+ {currentStep.notes && (
+
+ {currentStep.notes}
+
+ )}
+
+ {/* Next step preview */}
+ {nextStep && (
+
+ Next:
+
+ {nextStep.label}
+
+
+ {nextStep.durationMinutes}m
+
+
+ )}
+
+ {/* Actions */}
+
+ {routine.status === 'active' && (
+
+ )}
+
+ {isPaused && (
+
+ )}
+
+
+
+
+
+
+
+
+ {/* Stats row */}
+
+ Completed: {getCompletedStepCount(routine)}/{routine.steps.length}
+ Duration: {currentStep.durationMinutes}m
+ Total: {routine.totalDurationMinutes}m
+
+
+ );
+}
diff --git a/web/src/lib/linked-timers.test.ts b/web/src/lib/linked-timers.test.ts
new file mode 100644
index 0000000..1f2cfe1
--- /dev/null
+++ b/web/src/lib/linked-timers.test.ts
@@ -0,0 +1,236 @@
+import { describe, it, expect } from 'vitest';
+import {
+ buildChain,
+ appendToChain,
+ removeFromChain,
+ getNextInChain,
+ findChainForTimer,
+ getChainPosition,
+ hasDownstreamTimers,
+ getDownstreamTimerIds,
+ getChainTimers,
+ CHAIN_PRESETS,
+} from './linked-timers';
+import type { Timer } from './timer-engine';
+
+function makeTimer(id: string): Timer {
+ const now = Date.now();
+ return {
+ id,
+ type: 'countdown',
+ label: `Timer ${id}`,
+ urgency: 'standard',
+ state: 'active',
+ targetTime: now + 60_000,
+ duration: 60_000,
+ createdAt: now,
+ startedAt: now,
+ pausedAt: null,
+ firedAt: null,
+ dismissedAt: null,
+ completedAt: null,
+ elapsedBeforePause: 0,
+ cascade: { preset: 'none', intervals: [] },
+ warnings: [],
+ snoozeCount: 0,
+ snoozedUntil: null,
+ };
+}
+
+describe('buildChain', () => {
+ it('creates a chain with correct links', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ expect(chain.name).toBe('Test');
+ expect(chain.timerIds).toEqual(['a', 'b', 'c']);
+ expect(chain.links).toHaveLength(2);
+ expect(chain.links[0]).toEqual({ fromId: 'a', toId: 'b', delay: 0 });
+ expect(chain.links[1]).toEqual({ fromId: 'b', toId: 'c', delay: 0 });
+ });
+
+ it('creates a chain with custom delay', () => {
+ const chain = buildChain('Delayed', ['x', 'y'], 5000);
+ expect(chain.links[0].delay).toBe(5000);
+ });
+
+ it('handles single timer (no links)', () => {
+ const chain = buildChain('Solo', ['a']);
+ expect(chain.timerIds).toEqual(['a']);
+ expect(chain.links).toHaveLength(0);
+ });
+
+ it('handles empty timer list', () => {
+ const chain = buildChain('Empty', []);
+ expect(chain.timerIds).toEqual([]);
+ expect(chain.links).toHaveLength(0);
+ });
+});
+
+describe('appendToChain', () => {
+ it('appends a timer to end of chain', () => {
+ const chain = buildChain('Base', ['a', 'b']);
+ const extended = appendToChain(chain, 'c', 1000);
+ expect(extended.timerIds).toEqual(['a', 'b', 'c']);
+ expect(extended.links).toHaveLength(2);
+ expect(extended.links[1]).toEqual({ fromId: 'b', toId: 'c', delay: 1000 });
+ });
+
+ it('preserves existing links', () => {
+ const chain = buildChain('Base', ['a', 'b'], 500);
+ const extended = appendToChain(chain, 'c');
+ expect(extended.links[0].delay).toBe(500);
+ expect(extended.links[1].delay).toBe(0);
+ });
+});
+
+describe('removeFromChain', () => {
+ it('removes a middle timer and relinks', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const reduced = removeFromChain(chain, 'b');
+ expect(reduced.timerIds).toEqual(['a', 'c']);
+ expect(reduced.links).toHaveLength(1);
+ expect(reduced.links[0].fromId).toBe('a');
+ expect(reduced.links[0].toId).toBe('c');
+ });
+
+ it('removes first timer', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const reduced = removeFromChain(chain, 'a');
+ expect(reduced.timerIds).toEqual(['b', 'c']);
+ expect(reduced.links).toHaveLength(1);
+ });
+
+ it('removes last timer', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const reduced = removeFromChain(chain, 'c');
+ expect(reduced.timerIds).toEqual(['a', 'b']);
+ expect(reduced.links).toHaveLength(1);
+ });
+
+ it('returns unchanged if timer not in chain', () => {
+ const chain = buildChain('Test', ['a', 'b']);
+ const same = removeFromChain(chain, 'z');
+ expect(same.timerIds).toEqual(['a', 'b']);
+ });
+});
+
+describe('getNextInChain', () => {
+ it('finds the next link after a completed timer', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const link = getNextInChain([chain], 'a');
+ expect(link).not.toBeNull();
+ expect(link!.toId).toBe('b');
+ });
+
+ it('returns null for last timer in chain', () => {
+ const chain = buildChain('Test', ['a', 'b']);
+ const link = getNextInChain([chain], 'b');
+ expect(link).toBeNull();
+ });
+
+ it('returns null for unknown timer', () => {
+ const chain = buildChain('Test', ['a', 'b']);
+ const link = getNextInChain([chain], 'z');
+ expect(link).toBeNull();
+ });
+
+ it('searches across multiple chains', () => {
+ const c1 = buildChain('Chain1', ['a', 'b']);
+ const c2 = buildChain('Chain2', ['x', 'y']);
+ const link = getNextInChain([c1, c2], 'x');
+ expect(link!.toId).toBe('y');
+ });
+});
+
+describe('findChainForTimer', () => {
+ it('finds the chain containing a timer', () => {
+ const c1 = buildChain('C1', ['a', 'b']);
+ const c2 = buildChain('C2', ['x', 'y']);
+ const found = findChainForTimer([c1, c2], 'x');
+ expect(found!.name).toBe('C2');
+ });
+
+ it('returns null if not found', () => {
+ const chain = buildChain('C1', ['a', 'b']);
+ expect(findChainForTimer([chain], 'z')).toBeNull();
+ });
+});
+
+describe('getChainPosition', () => {
+ it('returns correct position', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const pos = getChainPosition([chain], 'b');
+ expect(pos!.index).toBe(1);
+ expect(pos!.total).toBe(3);
+ });
+
+ it('returns null for unknown timer', () => {
+ const chain = buildChain('Test', ['a']);
+ expect(getChainPosition([chain], 'z')).toBeNull();
+ });
+});
+
+describe('hasDownstreamTimers', () => {
+ it('returns true for non-last timer', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ expect(hasDownstreamTimers([chain], 'a')).toBe(true);
+ expect(hasDownstreamTimers([chain], 'b')).toBe(true);
+ });
+
+ it('returns false for last timer', () => {
+ const chain = buildChain('Test', ['a', 'b']);
+ expect(hasDownstreamTimers([chain], 'b')).toBe(false);
+ });
+
+ it('returns false for unknown timer', () => {
+ const chain = buildChain('Test', ['a']);
+ expect(hasDownstreamTimers([chain], 'z')).toBe(false);
+ });
+});
+
+describe('getDownstreamTimerIds', () => {
+ it('returns downstream IDs', () => {
+ const chain = buildChain('Test', ['a', 'b', 'c', 'd']);
+ expect(getDownstreamTimerIds([chain], 'b')).toEqual(['c', 'd']);
+ });
+
+ it('returns empty for last timer', () => {
+ const chain = buildChain('Test', ['a', 'b']);
+ expect(getDownstreamTimerIds([chain], 'b')).toEqual([]);
+ });
+});
+
+describe('getChainTimers', () => {
+ it('resolves timer objects', () => {
+ const timers = [makeTimer('a'), makeTimer('b'), makeTimer('c')];
+ const chain = buildChain('Test', ['a', 'b', 'c']);
+ const resolved = getChainTimers(chain, timers);
+ expect(resolved).toHaveLength(3);
+ expect(resolved[0]!.id).toBe('a');
+ expect(resolved[2]!.id).toBe('c');
+ });
+
+ it('returns null for missing timers', () => {
+ const timers = [makeTimer('a')];
+ const chain = buildChain('Test', ['a', 'missing']);
+ const resolved = getChainTimers(chain, timers);
+ expect(resolved[0]!.id).toBe('a');
+ expect(resolved[1]).toBeNull();
+ });
+});
+
+describe('CHAIN_PRESETS', () => {
+ it('has at least 3 presets', () => {
+ expect(CHAIN_PRESETS.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('each preset has steps with durations', () => {
+ for (const preset of CHAIN_PRESETS) {
+ expect(preset.name).toBeTruthy();
+ expect(preset.steps.length).toBeGreaterThan(0);
+ for (const step of preset.steps) {
+ expect(step.label).toBeTruthy();
+ expect(step.durationMs).toBeGreaterThan(0);
+ }
+ }
+ });
+});
diff --git a/web/src/lib/linked-timers.ts b/web/src/lib/linked-timers.ts
new file mode 100644
index 0000000..064629b
--- /dev/null
+++ b/web/src/lib/linked-timers.ts
@@ -0,0 +1,212 @@
+// ── Linked Timers ─────────────────────────────────────────────
+// "When timer A ends, start timer B" chain relationships
+
+import type { Timer } from './timer-engine';
+
+// ── Types ─────────────────────────────────────────────────────
+
+export interface TimerLink {
+ fromId: string; // timer that triggers
+ toId: string; // timer that starts
+ delay: number; // ms delay between from completing and to starting (0 = immediate)
+}
+
+export interface TimerChain {
+ id: string;
+ name: string;
+ links: TimerLink[];
+ timerIds: string[]; // ordered list of timer IDs in chain
+}
+
+// ── Chain Building ────────────────────────────────────────────
+
+/**
+ * Build a chain from an ordered list of timer IDs.
+ * Each timer links to the next with the specified delay.
+ */
+export function buildChain(
+ name: string,
+ timerIds: string[],
+ delayMs: number = 0
+): TimerChain {
+ const links: TimerLink[] = [];
+ for (let i = 0; i < timerIds.length - 1; i++) {
+ links.push({
+ fromId: timerIds[i],
+ toId: timerIds[i + 1],
+ delay: delayMs,
+ });
+ }
+
+ return {
+ id: `chain-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ links,
+ timerIds,
+ };
+}
+
+/**
+ * Add a timer to the end of an existing chain.
+ */
+export function appendToChain(
+ chain: TimerChain,
+ timerId: string,
+ delayMs: number = 0
+): TimerChain {
+ const lastId = chain.timerIds[chain.timerIds.length - 1];
+ if (!lastId) return chain;
+
+ return {
+ ...chain,
+ timerIds: [...chain.timerIds, timerId],
+ links: [
+ ...chain.links,
+ { fromId: lastId, toId: timerId, delay: delayMs },
+ ],
+ };
+}
+
+/**
+ * Remove a timer from a chain. Relinks neighbours if possible.
+ */
+export function removeFromChain(
+ chain: TimerChain,
+ timerId: string
+): TimerChain {
+ const idx = chain.timerIds.indexOf(timerId);
+ if (idx === -1) return chain;
+
+ const newTimerIds = chain.timerIds.filter((id) => id !== timerId);
+
+ // Rebuild links from the new ordered list
+ const newLinks: TimerLink[] = [];
+ for (let i = 0; i < newTimerIds.length - 1; i++) {
+ // Try to preserve original delay
+ const existingLink = chain.links.find(
+ (l) => l.fromId === newTimerIds[i] && l.toId === newTimerIds[i + 1]
+ );
+ newLinks.push({
+ fromId: newTimerIds[i],
+ toId: newTimerIds[i + 1],
+ delay: existingLink?.delay ?? 0,
+ });
+ }
+
+ return {
+ ...chain,
+ timerIds: newTimerIds,
+ links: newLinks,
+ };
+}
+
+// ── Chain Queries ─────────────────────────────────────────────
+
+/**
+ * Find the next timer to start when a timer completes/fires.
+ */
+export function getNextInChain(
+ chains: TimerChain[],
+ completedTimerId: string
+): TimerLink | null {
+ for (const chain of chains) {
+ const link = chain.links.find((l) => l.fromId === completedTimerId);
+ if (link) return link;
+ }
+ return null;
+}
+
+/**
+ * Find the chain a timer belongs to.
+ */
+export function findChainForTimer(
+ chains: TimerChain[],
+ timerId: string
+): TimerChain | null {
+ return chains.find((c) => c.timerIds.includes(timerId)) ?? null;
+}
+
+/**
+ * Get the position of a timer within its chain.
+ * Returns null if timer is not in any chain.
+ */
+export function getChainPosition(
+ chains: TimerChain[],
+ timerId: string
+): { chain: TimerChain; index: number; total: number } | null {
+ const chain = findChainForTimer(chains, timerId);
+ if (!chain) return null;
+ const index = chain.timerIds.indexOf(timerId);
+ return { chain, index, total: chain.timerIds.length };
+}
+
+/**
+ * Get all timers in a chain with their labels, for visualization.
+ */
+export function getChainTimers(
+ chain: TimerChain,
+ allTimers: Timer[]
+): (Timer | null)[] {
+ return chain.timerIds.map((id) => allTimers.find((t) => t.id === id) ?? null);
+}
+
+/**
+ * Check if cancelling a timer should prompt to cancel the whole chain.
+ */
+export function hasDownstreamTimers(
+ chains: TimerChain[],
+ timerId: string
+): boolean {
+ const chain = findChainForTimer(chains, timerId);
+ if (!chain) return false;
+ const idx = chain.timerIds.indexOf(timerId);
+ return idx < chain.timerIds.length - 1;
+}
+
+/**
+ * Get all downstream timer IDs (timers that follow this one in chain).
+ */
+export function getDownstreamTimerIds(
+ chains: TimerChain[],
+ timerId: string
+): string[] {
+ const chain = findChainForTimer(chains, timerId);
+ if (!chain) return [];
+ const idx = chain.timerIds.indexOf(timerId);
+ if (idx === -1) return [];
+ return chain.timerIds.slice(idx + 1);
+}
+
+// ── Built-in Chain Presets ────────────────────────────────────
+
+export interface ChainPreset {
+ name: string;
+ steps: { label: string; durationMs: number }[];
+}
+
+export const CHAIN_PRESETS: ChainPreset[] = [
+ {
+ name: 'Pasta Timer',
+ steps: [
+ { label: 'Boil Water', durationMs: 10 * 60_000 },
+ { label: 'Cook Pasta', durationMs: 12 * 60_000 },
+ { label: 'Prepare Sauce', durationMs: 8 * 60_000 },
+ ],
+ },
+ {
+ name: 'Laundry',
+ steps: [
+ { label: 'Washer Cycle', durationMs: 45 * 60_000 },
+ { label: 'Transfer to Dryer', durationMs: 5 * 60_000 },
+ { label: 'Dryer Cycle', durationMs: 60 * 60_000 },
+ ],
+ },
+ {
+ name: 'Meeting Prep',
+ steps: [
+ { label: 'Review Notes', durationMs: 10 * 60_000 },
+ { label: 'Get Ready', durationMs: 5 * 60_000 },
+ { label: 'Travel / Join Call', durationMs: 5 * 60_000 },
+ ],
+ },
+];
diff --git a/web/src/lib/routine-store.ts b/web/src/lib/routine-store.ts
new file mode 100644
index 0000000..33dcab0
--- /dev/null
+++ b/web/src/lib/routine-store.ts
@@ -0,0 +1,137 @@
+// ── Zustand Store for Routines ────────────────────────────────
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import type { Routine, CreateRoutineParams } from './routines';
+import {
+ createRoutine,
+ startRoutine,
+ pauseRoutine,
+ resumeRoutine,
+ completeCurrentStep,
+ skipCurrentStep,
+ cancelRoutine,
+ instantiateTemplate,
+ getBuiltInTemplates,
+ shouldStepComplete,
+} from './routines';
+
+export interface RoutineStore {
+ routines: Routine[];
+ templates: Routine[];
+
+ // CRUD
+ addRoutine: (params: CreateRoutineParams) => Routine;
+ addTemplate: (params: CreateRoutineParams) => Routine;
+ removeRoutine: (id: string) => void;
+ removeTemplate: (id: string) => void;
+
+ // State transitions
+ start: (id: string) => void;
+ pause: (id: string) => void;
+ resume: (id: string) => void;
+ completeStep: (id: string) => void;
+ skipStep: (id: string) => void;
+ cancel: (id: string) => void;
+
+ // Template
+ startFromTemplate: (templateId: string) => Routine | null;
+
+ // Tick — auto-advance steps
+ tickRoutines: (now: number) => void;
+
+ // Helpers
+ getRoutine: (id: string) => Routine | undefined;
+ getActiveRoutine: () => Routine | undefined;
+}
+
+function updateRoutine(routines: Routine[], id: string, updater: (r: Routine) => Routine): Routine[] {
+ return routines.map((r) => (r.id === id ? updater(r) : r));
+}
+
+export const useRoutineStore = create()(
+ persist(
+ (set, get) => ({
+ routines: [],
+ templates: getBuiltInTemplates(),
+
+ addRoutine: (params) => {
+ const routine = createRoutine(params);
+ set((s) => ({ routines: [...s.routines, routine] }));
+ return routine;
+ },
+
+ addTemplate: (params) => {
+ const template = createRoutine({ ...params, isTemplate: true });
+ set((s) => ({ templates: [...s.templates, template] }));
+ return template;
+ },
+
+ removeRoutine: (id) => {
+ set((s) => ({ routines: s.routines.filter((r) => r.id !== id) }));
+ },
+
+ removeTemplate: (id) => {
+ set((s) => ({ templates: s.templates.filter((r) => r.id !== id) }));
+ },
+
+ start: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, startRoutine) }));
+ },
+
+ pause: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, pauseRoutine) }));
+ },
+
+ resume: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, resumeRoutine) }));
+ },
+
+ completeStep: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, completeCurrentStep) }));
+ },
+
+ skipStep: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, skipCurrentStep) }));
+ },
+
+ cancel: (id) => {
+ set((s) => ({ routines: updateRoutine(s.routines, id, cancelRoutine) }));
+ },
+
+ startFromTemplate: (templateId) => {
+ const template = get().templates.find((t) => t.id === templateId);
+ if (!template) return null;
+ const routine = startRoutine(instantiateTemplate(template));
+ set((s) => ({ routines: [...s.routines, routine] }));
+ return routine;
+ },
+
+ tickRoutines: (now) => {
+ const { routines } = get();
+ let changed = false;
+ const updated = routines.map((r) => {
+ if (r.status === 'active' && shouldStepComplete(r, now)) {
+ changed = true;
+ return completeCurrentStep(r);
+ }
+ return r;
+ });
+ if (changed) set({ routines: updated });
+ },
+
+ getRoutine: (id) => get().routines.find((r) => r.id === id),
+
+ getActiveRoutine: () => get().routines.find((r) => r.status === 'active' || r.status === 'paused'),
+ }),
+ {
+ name: 'chronomind-routines',
+ storage: createJSONStorage(() => {
+ if (typeof window === 'undefined') {
+ return { getItem: () => null, setItem: () => {}, removeItem: () => {} };
+ }
+ return localStorage;
+ }),
+ partialize: (state) => ({ routines: state.routines, templates: state.templates }),
+ }
+ )
+);