feat(web): routine editor/runner UI, linked timers, routines page

- RoutineEditor component: add/remove/reorder steps, transition types, notes, save-as-template toggle, total duration display
- RoutineRunner component: countdown ring, step dots, progress bar, next step preview, pause/resume/skip/done/cancel, completion celebration
- Routine store (lib/routine-store.ts): Zustand + localStorage persistence, template management, auto-advance via tickRoutines()
- Routines page (app/routines/): template browser, active runner, ready queue, past routines
- Linked timers engine (lib/linked-timers.ts): chain building/append/remove with relinking, 3 presets (Pasta, Laundry, Meeting Prep), downstream queries
- Linked timers tests (27 tests)
- Dashboard: added Routines + nav link
- Updated roadmap.md Week 3 items
- 329 tests passing (14 test files), tsc clean
This commit is contained in:
saravanakumardb1 2026-02-27 22:07:16 -08:00
parent 0b798bf9ff
commit fc05ea12ba
8 changed files with 1339 additions and 19 deletions

View File

@ -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)

View File

@ -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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-3xl mx-auto px-4 py-8">
<Link href="/" className="flex items-center gap-2 text-sm mb-6" style={{ color: 'var(--cm-accent)' }}>
<ArrowLeft size={16} /> Back to Dashboard
</Link>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: 'var(--cm-text-primary)' }}>Routines</h1>
<button
onClick={() => setShowEditor(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Plus size={16} /> New Routine
</button>
</div>
{/* Active routine runner */}
{activeRoutine && (
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3 flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
<Play size={14} /> Running
</h2>
<RoutineRunner routine={activeRoutine} />
</div>
)}
{/* Templates */}
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3 flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
<Copy size={14} /> Templates ({templates.length})
</h2>
{templates.length > 0 ? (
<div className="grid gap-3">
{templates.map((tmpl) => (
<div
key={tmpl.id}
className="rounded-xl border p-4 flex items-center gap-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="flex-1">
<div className="font-medium text-sm" style={{ color: 'var(--cm-text-primary)' }}>{tmpl.name}</div>
{tmpl.description && (
<div className="text-xs mt-0.5" style={{ color: 'var(--cm-text-tertiary)' }}>{tmpl.description}</div>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<span className="flex items-center gap-1"><ListChecks size={12} /> {tmpl.steps.length} steps</span>
<span className="flex items-center gap-1"><Clock size={12} /> {tmpl.totalDurationMinutes}m</span>
</div>
</div>
<button
onClick={() => startFromTemplate(tmpl.id)}
disabled={!!activeRoutine}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer disabled:opacity-30"
style={{ backgroundColor: 'rgba(52,211,153,0.15)', color: 'var(--cm-success)' }}
>
<Play size={14} /> Start
</button>
{!tmpl.isTemplate || tmpl.createdAt > Date.now() - 1000 ? (
<button
onClick={() => removeTemplate(tmpl.id)}
className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<Trash2 size={14} />
</button>
) : null}
</div>
))}
</div>
) : (
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>No templates yet. Create one above.</p>
)}
</div>
{/* Ready routines */}
{readyRoutines.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3 flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
<ListChecks size={14} /> Ready ({readyRoutines.length})
</h2>
<div className="grid gap-3">
{readyRoutines.map((r) => (
<div
key={r.id}
className="rounded-xl border p-4 flex items-center gap-4"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="flex-1">
<div className="font-medium text-sm" style={{ color: 'var(--cm-text-primary)' }}>{r.name}</div>
<div className="flex items-center gap-3 mt-1 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<span>{r.steps.length} steps</span>
<span>{r.totalDurationMinutes}m</span>
</div>
</div>
<button
onClick={() => start(r.id)}
disabled={!!activeRoutine}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer disabled:opacity-30"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Play size={14} /> Start
</button>
<button
onClick={() => removeRoutine(r.id)}
className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
)}
{/* Past routines */}
{pastRoutines.length > 0 && (
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
Recent ({pastRoutines.length})
</h2>
<div className="grid gap-2">
{pastRoutines.map((r) => (
<div
key={r.id}
className="rounded-xl border p-3 flex items-center gap-3"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<RoutineRunner routine={r} />
</div>
))}
</div>
</div>
)}
<RoutineEditor isOpen={showEditor} onClose={() => setShowEditor(false)} onSave={handleSave} />
</div>
</div>
);
}

View File

@ -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() {
>
<Eye size={18} />
</Link>
<Link
href="/routines"
className="p-2 rounded-lg transition-colors"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Routines"
>
<ListChecks size={18} />
</Link>
<Link
href="/history"
className="p-2 rounded-lg transition-colors"

View File

@ -0,0 +1,291 @@
'use client';
import { useState } from 'react';
import { Plus, X, GripVertical, Trash2, Clock } from 'lucide-react';
import type { TransitionType } from '@/lib/routines';
import { TRANSITION_LABELS, calculateTotalDuration, createRoutineStep } from '@/lib/routines';
import type { RoutineStep } from '@/lib/routines';
interface StepDraft {
id: string;
label: string;
durationMinutes: number;
transition: TransitionType;
customTransitionMinutes: number;
notes: string;
}
interface RoutineEditorProps {
isOpen: boolean;
onClose: () => 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<StepDraft[]>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div
className="relative w-full max-w-lg mx-4 rounded-2xl border shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)' }}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
{initialName ? 'Edit Routine' : 'New Routine'}
</h2>
<button onClick={onClose} className="p-1 rounded-lg cursor-pointer" style={{ color: 'var(--cm-text-tertiary)' }}>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Name + description */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>Name</label>
<input
type="text"
value={name}
onChange={(e) => 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)' }}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>Description (optional)</label>
<input
type="text"
value={description}
onChange={(e) => 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)' }}
/>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>Steps</label>
<div className="flex items-center gap-1 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<Clock size={12} /> {totalMinutes} min total
</div>
</div>
<div className="space-y-2">
{steps.map((step, idx) => (
<div
key={step.id}
className="rounded-xl border p-3"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="flex items-start gap-2">
{/* Reorder buttons */}
<div className="flex flex-col gap-0.5 pt-1">
<button
onClick={() => moveStep(idx, idx - 1)}
disabled={idx === 0}
className="text-xs disabled:opacity-20 cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
></button>
<button
onClick={() => moveStep(idx, idx + 1)}
disabled={idx === steps.length - 1}
className="text-xs disabled:opacity-20 cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
></button>
</div>
<div className="flex-1 space-y-2">
{/* Step number + label */}
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-bold w-5 text-center" style={{ color: 'var(--cm-accent)' }}>{idx + 1}</span>
<input
type="text"
value={step.label}
onChange={(e) => 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)' }}
/>
</div>
{/* Duration + transition */}
<div className="flex items-center gap-2 ml-7">
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={180}
value={step.durationMinutes}
onChange={(e) => 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)' }}
/>
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>min</span>
</div>
{idx < steps.length - 1 && (
<select
value={step.transition}
onChange={(e) => updateStep(step.id, 'transition', e.target.value)}
className="px-2 py-1 rounded-md border text-xs cursor-pointer"
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)', color: 'var(--cm-text-secondary)' }}
>
{Object.entries(TRANSITION_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
)}
{step.transition === 'custom' && idx < steps.length - 1 && (
<div className="flex items-center gap-1">
<input
type="number"
min={1}
max={60}
value={step.customTransitionMinutes}
onChange={(e) => 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)' }}
/>
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>min</span>
</div>
)}
</div>
{/* Notes */}
<input
type="text"
value={step.notes}
onChange={(e) => 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)' }}
/>
</div>
{/* Delete */}
<button
onClick={() => removeStep(step.id)}
disabled={steps.length <= 1}
className="p-1 rounded cursor-pointer disabled:opacity-20"
style={{ color: 'var(--cm-danger)' }}
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
<button
onClick={addStep}
className="mt-2 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-accent)' }}
>
<Plus size={14} /> Add Step
</button>
</div>
{/* Save as template toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={saveAsTemplate}
onChange={(e) => setSaveAsTemplate(e.target.checked)}
className="rounded"
/>
<span className="text-xs" style={{ color: 'var(--cm-text-secondary)' }}>Save as reusable template</span>
</label>
</div>
{/* Footer */}
<div className="p-4 border-t flex gap-2" style={{ borderColor: 'var(--cm-border)' }}>
<button
onClick={onClose}
className="flex-1 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!canSave}
className="flex-1 py-2.5 rounded-xl text-sm font-semibold cursor-pointer disabled:opacity-40"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
{saveAsTemplate ? 'Save Template' : 'Create Routine'}
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div
className="rounded-2xl border p-8 text-center"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
background: isCompleted
? 'linear-gradient(135deg, var(--cm-surface-card) 0%, rgba(52,211,153,0.08) 100%)'
: undefined,
}}
>
<div className="flex justify-center mb-4">
<div
className="w-16 h-16 rounded-full flex items-center justify-center"
style={{ backgroundColor: isCompleted ? 'rgba(52,211,153,0.15)' : 'rgba(255,107,107,0.15)' }}
>
{isCompleted ? (
<Trophy size={32} style={{ color: 'var(--cm-success)' }} />
) : (
<X size={32} style={{ color: 'var(--cm-danger)' }} />
)}
</div>
</div>
<h3
className="text-xl font-bold mb-2"
style={{ color: isCompleted ? 'var(--cm-success)' : 'var(--cm-text-primary)' }}
>
{isCompleted ? 'Routine Complete!' : 'Routine Cancelled'}
</h3>
<p className="text-sm mb-1" style={{ color: 'var(--cm-text-primary)' }}>{routine.name}</p>
<p className="text-xs mb-4" style={{ color: 'var(--cm-text-tertiary)' }}>
{completed} completed &middot; {skipped} skipped &middot; {routine.steps.length} total steps
</p>
{/* Step summary */}
<div className="flex justify-center gap-1 mb-4">
{routine.steps.map((step) => (
<div
key={step.id}
className="w-3 h-3 rounded-full"
style={{
backgroundColor:
step.status === 'completed' ? 'var(--cm-success)'
: step.status === 'skipped' ? 'var(--cm-warning)'
: 'var(--cm-surface-muted)',
}}
title={`${step.label}: ${step.status}`}
/>
))}
</div>
</div>
);
}
if (!currentStep) return null;
return (
<div
className="rounded-2xl border p-6"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<ListChecks size={18} style={{ color: 'var(--cm-accent)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
{routine.name}
</span>
</div>
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
Step {routine.currentStepIndex + 1} of {routine.steps.length}
</span>
</div>
{/* Overall progress bar */}
<div className="w-full h-1.5 rounded-full mb-6" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progress * 100}%`, backgroundColor: 'var(--cm-accent)' }}
/>
</div>
{/* Step dots */}
<div className="flex justify-center gap-1.5 mb-6">
{routine.steps.map((step, idx) => (
<div
key={step.id}
className="w-3 h-3 rounded-full transition-colors"
style={{
backgroundColor:
step.status === 'completed' ? 'var(--cm-success)'
: step.status === 'skipped' ? 'var(--cm-warning)'
: idx === routine.currentStepIndex ? 'var(--cm-accent)'
: 'var(--cm-surface-muted)',
}}
title={step.label}
/>
))}
</div>
{/* Countdown ring */}
<div className="flex justify-center mb-4">
<CountdownRing progress={stepProgress} size={200} strokeWidth={10} color="var(--cm-accent)">
<div className="text-center">
<div className="text-3xl font-mono font-bold tabular-nums" style={{ color: 'var(--cm-text-primary)' }}>
{formatDuration(remainingMs)}
</div>
<div className="text-xs mt-1 max-w-[140px] truncate" style={{ color: 'var(--cm-text-tertiary)' }}>
{currentStep.label}
</div>
</div>
</CountdownRing>
</div>
{/* Paused indicator */}
{isPaused && (
<p className="text-sm mb-4 text-center font-medium" style={{ color: 'var(--cm-warning)' }}>
Paused
</p>
)}
{/* Current step notes */}
{currentStep.notes && (
<p className="text-xs text-center mb-4 italic" style={{ color: 'var(--cm-text-tertiary)' }}>
{currentStep.notes}
</p>
)}
{/* Next step preview */}
{nextStep && (
<div
className="rounded-lg px-3 py-2 mb-4 flex items-center gap-2"
style={{ backgroundColor: 'var(--cm-surface-muted)' }}
>
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>Next:</span>
<span className="text-xs font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
{nextStep.label}
</span>
<span className="text-xs ml-auto" style={{ color: 'var(--cm-text-tertiary)' }}>
{nextStep.durationMinutes}m
</span>
</div>
)}
{/* Actions */}
<div className="flex justify-center gap-3">
{routine.status === 'active' && (
<button
onClick={() => pause(routine.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
<Pause size={16} /> Pause
</button>
)}
{isPaused && (
<button
onClick={() => resume(routine.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Play size={16} /> Resume
</button>
)}
<button
onClick={() => completeStep(routine.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'rgba(52,211,153,0.15)', color: 'var(--cm-success)' }}
>
<CheckCircle2 size={16} /> Done
</button>
<button
onClick={() => skipStep(routine.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
>
<SkipForward size={16} /> Skip
</button>
<button
onClick={() => cancel(routine.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium cursor-pointer"
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
>
<X size={16} /> End
</button>
</div>
{/* Stats row */}
<div className="mt-4 flex justify-center gap-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<span>Completed: {getCompletedStepCount(routine)}/{routine.steps.length}</span>
<span>Duration: {currentStep.durationMinutes}m</span>
<span>Total: {routine.totalDurationMinutes}m</span>
</div>
</div>
);
}

View File

@ -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);
}
}
});
});

View File

@ -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 },
],
},
];

View File

@ -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<RoutineStore>()(
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 }),
}
)
);