- 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
237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
});
|