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 && ( +
+

+ Running +

+ +
+ )} + + {/* 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 }), + } + ) +);