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:
parent
0b798bf9ff
commit
fc05ea12ba
@ -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)
|
||||
|
||||
184
web/src/app/routines/page.tsx
Normal file
184
web/src/app/routines/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
291
web/src/components/RoutineEditor.tsx
Normal file
291
web/src/components/RoutineEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
web/src/components/RoutineRunner.tsx
Normal file
246
web/src/components/RoutineRunner.tsx
Normal 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 · {skipped} skipped · {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>
|
||||
);
|
||||
}
|
||||
236
web/src/lib/linked-timers.test.ts
Normal file
236
web/src/lib/linked-timers.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
212
web/src/lib/linked-timers.ts
Normal file
212
web/src/lib/linked-timers.ts
Normal 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
137
web/src/lib/routine-store.ts
Normal file
137
web/src/lib/routine-store.ts
Normal 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 }),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user