feat(web): add routine engine, NL parser, and context messages
- Routine engine: data model, state machine (start/pause/resume/complete/skip/cancel), 4 built-in templates (Morning, Workout, Cooking, Wind-Down), template instantiation - NL parser: regex-based parsing for relative times, absolute times, durations, pomodoro, urgency detection, label extraction (no LLM dependency) - Context messages: keyword→message rules for 20+ categories (meetings, flights, cooking, health, etc.), warning message formatting - 105 unit tests (45 routines + 37 NL parser + 23 context messages)
This commit is contained in:
parent
885a2685ad
commit
8fe5e8e787
127
web/src/lib/context-messages.test.ts
Normal file
127
web/src/lib/context-messages.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getContextMessage,
|
||||
getAllContextMessages,
|
||||
getWarningMessage,
|
||||
hasContextMatch,
|
||||
getContextRules,
|
||||
} from './context-messages';
|
||||
|
||||
describe('context-messages', () => {
|
||||
describe('getContextMessage', () => {
|
||||
it('returns message for "meeting"', () => {
|
||||
expect(getContextMessage('Team Meeting')).toBe('Review your agenda');
|
||||
});
|
||||
|
||||
it('returns message for "flight"', () => {
|
||||
expect(getContextMessage('Flight to NYC')).toBe('Check in online');
|
||||
});
|
||||
|
||||
it('returns message for "dentist"', () => {
|
||||
expect(getContextMessage('Dentist Appointment')).toBe('Bring your insurance card');
|
||||
});
|
||||
|
||||
it('returns message for "cooking"', () => {
|
||||
expect(getContextMessage('Start cooking dinner')).toBe('Preheat the oven');
|
||||
});
|
||||
|
||||
it('returns message for "pasta"', () => {
|
||||
expect(getContextMessage('Pasta timer')).toBe('Start boiling water');
|
||||
});
|
||||
|
||||
it('returns message for "workout"', () => {
|
||||
expect(getContextMessage('Morning workout')).toBe('Hydrate beforehand');
|
||||
});
|
||||
|
||||
it('returns message for "deadline"', () => {
|
||||
expect(getContextMessage('Project deadline')).toBe('Final review before submitting');
|
||||
});
|
||||
|
||||
it('returns message for "interview"', () => {
|
||||
expect(getContextMessage('Job interview')).toBe('Review the job description');
|
||||
});
|
||||
|
||||
it('returns message for "presentation"', () => {
|
||||
expect(getContextMessage('Client presentation')).toBe('Run through your slides');
|
||||
});
|
||||
|
||||
it('returns message for "sleep" / "bedtime"', () => {
|
||||
expect(getContextMessage('Bedtime')).toBe('Start winding down');
|
||||
});
|
||||
|
||||
it('returns null for unknown label', () => {
|
||||
expect(getContextMessage('Random thing')).toBeNull();
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(getContextMessage('TEAM MEETING')).toBe('Review your agenda');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllContextMessages', () => {
|
||||
it('returns multiple messages for label matching multiple rules', () => {
|
||||
// "dinner cooking" matches both cooking and dinner/meal rules
|
||||
const messages = getAllContextMessages('cooking dinner');
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(messages).toContain('Preheat the oven');
|
||||
expect(messages).toContain('Start prepping ingredients');
|
||||
});
|
||||
|
||||
it('returns empty array for no match', () => {
|
||||
expect(getAllContextMessages('something random')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWarningMessage', () => {
|
||||
it('formats message with context for matched label', () => {
|
||||
const msg = getWarningMessage('Team Meeting', 30);
|
||||
expect(msg).toBe('Team Meeting in 30m — Review your agenda');
|
||||
});
|
||||
|
||||
it('formats message without context for unmatched label', () => {
|
||||
const msg = getWarningMessage('Random Timer', 15);
|
||||
expect(msg).toBe('Random Timer in 15m');
|
||||
});
|
||||
|
||||
it('formats hours correctly', () => {
|
||||
const msg = getWarningMessage('Flight to NYC', 120);
|
||||
expect(msg).toBe('Flight to NYC in 2h — Check in online');
|
||||
});
|
||||
|
||||
it('formats hours and minutes correctly', () => {
|
||||
const msg = getWarningMessage('Flight to NYC', 90);
|
||||
expect(msg).toBe('Flight to NYC in 1h 30m — Check in online');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasContextMatch', () => {
|
||||
it('returns true for matched label', () => {
|
||||
expect(hasContextMatch('Team Meeting')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unmatched label', () => {
|
||||
expect(hasContextMatch('Something else')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextRules', () => {
|
||||
it('returns a non-empty array of rules', () => {
|
||||
const rules = getContextRules();
|
||||
expect(rules.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('each rule has keywords and messages', () => {
|
||||
const rules = getContextRules();
|
||||
rules.forEach((rule) => {
|
||||
expect(rule.keywords.length).toBeGreaterThan(0);
|
||||
expect(rule.messages.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a copy (not mutable)', () => {
|
||||
const rules1 = getContextRules();
|
||||
const rules2 = getContextRules();
|
||||
expect(rules1).not.toBe(rules2);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
web/src/lib/context-messages.ts
Normal file
171
web/src/lib/context-messages.ts
Normal file
@ -0,0 +1,171 @@
|
||||
// ── Contextual Pre-Warning Messages ───────────────────────────
|
||||
// Keyword → helpful prep message mapping. Expandable, no LLM needed.
|
||||
// Used in notification body and on timeline to give actionable context.
|
||||
|
||||
export interface ContextRule {
|
||||
keywords: string[];
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
// ── Rules Engine ───────────────────────────────────────────────
|
||||
|
||||
const CONTEXT_RULES: ContextRule[] = [
|
||||
// Meetings & calls
|
||||
{
|
||||
keywords: ['meeting', 'standup', 'sync', 'huddle', 'scrum', '1:1', 'one-on-one'],
|
||||
messages: ['Review your agenda', 'Check meeting notes', 'Prepare talking points'],
|
||||
},
|
||||
{
|
||||
keywords: ['call', 'phone', 'dial'],
|
||||
messages: ['Have the number ready', 'Review call notes', 'Find a quiet spot'],
|
||||
},
|
||||
{
|
||||
keywords: ['interview', 'screening'],
|
||||
messages: ['Review the job description', 'Prepare your questions', 'Test your camera and mic'],
|
||||
},
|
||||
{
|
||||
keywords: ['presentation', 'demo', 'pitch'],
|
||||
messages: ['Run through your slides', 'Check your screen sharing', 'Have backup ready'],
|
||||
},
|
||||
|
||||
// Travel & transport
|
||||
{
|
||||
keywords: ['flight', 'plane', 'airport'],
|
||||
messages: ['Check in online', 'Verify gate number', 'Pack your carry-on'],
|
||||
},
|
||||
{
|
||||
keywords: ['train', 'bus', 'subway', 'metro'],
|
||||
messages: ['Check for delays', 'Have your ticket ready'],
|
||||
},
|
||||
{
|
||||
keywords: ['drive', 'commute', 'carpool', 'uber', 'lyft', 'taxi', 'cab'],
|
||||
messages: ['Check traffic conditions', 'Grab your keys'],
|
||||
},
|
||||
{
|
||||
keywords: ['pickup', 'pick up', 'drop off', 'dropoff'],
|
||||
messages: ['Confirm the location', 'Check for any updates'],
|
||||
},
|
||||
|
||||
// Health & wellness
|
||||
{
|
||||
keywords: ['doctor', 'dentist', 'therapist', 'appointment', 'clinic', 'hospital'],
|
||||
messages: ['Bring your insurance card', 'Leave with travel buffer', 'Note any symptoms to mention'],
|
||||
},
|
||||
{
|
||||
keywords: ['medicine', 'medication', 'pill', 'vitamin'],
|
||||
messages: ['Take with water', 'Check dosage'],
|
||||
},
|
||||
{
|
||||
keywords: ['workout', 'exercise', 'gym', 'run', 'yoga', 'stretch'],
|
||||
messages: ['Hydrate beforehand', 'Change into workout clothes', 'Warm up first'],
|
||||
},
|
||||
|
||||
// Food & cooking
|
||||
{
|
||||
keywords: ['cook', 'cooking', 'bake', 'baking', 'oven', 'recipe'],
|
||||
messages: ['Preheat the oven', 'Gather your ingredients', 'Check you have everything'],
|
||||
},
|
||||
{
|
||||
keywords: ['pasta', 'noodle', 'rice', 'boil'],
|
||||
messages: ['Start boiling water', 'Salt the water'],
|
||||
},
|
||||
{
|
||||
keywords: ['dinner', 'lunch', 'breakfast', 'meal', 'eat'],
|
||||
messages: ['Start prepping ingredients', 'Set the table'],
|
||||
},
|
||||
{
|
||||
keywords: ['laundry', 'washer', 'dryer', 'clothes'],
|
||||
messages: ['Move clothes to dryer', 'Check pockets first'],
|
||||
},
|
||||
|
||||
// Work & productivity
|
||||
{
|
||||
keywords: ['deadline', 'due', 'submit', 'submission'],
|
||||
messages: ['Final review before submitting', 'Double-check requirements'],
|
||||
},
|
||||
{
|
||||
keywords: ['class', 'lecture', 'lesson', 'school', 'study'],
|
||||
messages: ['Pack your materials', 'Review last session notes'],
|
||||
},
|
||||
{
|
||||
keywords: ['focus', 'deep work', 'concentrate'],
|
||||
messages: ['Close unnecessary tabs', 'Put phone on silent', 'Grab water'],
|
||||
},
|
||||
|
||||
// Personal
|
||||
{
|
||||
keywords: ['birthday', 'anniversary', 'celebration', 'party'],
|
||||
messages: ['Check if the gift is ready', 'Confirm the plan'],
|
||||
},
|
||||
{
|
||||
keywords: ['walk', 'dog', 'pet'],
|
||||
messages: ['Grab the leash', 'Bring waste bags'],
|
||||
},
|
||||
{
|
||||
keywords: ['sleep', 'bed', 'bedtime', 'wind down', 'rest'],
|
||||
messages: ['Start winding down', 'Put away screens', 'Set tomorrow\'s alarm'],
|
||||
},
|
||||
{
|
||||
keywords: ['wake', 'morning', 'alarm'],
|
||||
messages: ['Time to get up!', 'Stretch and hydrate'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a contextual message for a timer label.
|
||||
* Returns the first matching message, or null if no match.
|
||||
*/
|
||||
export function getContextMessage(label: string): string | null {
|
||||
const lower = label.toLowerCase();
|
||||
for (const rule of CONTEXT_RULES) {
|
||||
if (rule.keywords.some((kw) => lower.includes(kw))) {
|
||||
return rule.messages[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all matching contextual messages for a timer label.
|
||||
* Returns an array of messages from all matching rules.
|
||||
*/
|
||||
export function getAllContextMessages(label: string): string[] {
|
||||
const lower = label.toLowerCase();
|
||||
const messages: string[] = [];
|
||||
for (const rule of CONTEXT_RULES) {
|
||||
if (rule.keywords.some((kw) => lower.includes(kw))) {
|
||||
messages.push(rule.messages[0]);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contextual message formatted for a pre-warning notification.
|
||||
* Includes the time remaining context.
|
||||
*/
|
||||
export function getWarningMessage(label: string, minutesBefore: number): string {
|
||||
const contextMsg = getContextMessage(label);
|
||||
const timeStr = minutesBefore >= 60
|
||||
? `${Math.floor(minutesBefore / 60)}h ${minutesBefore % 60 ? `${minutesBefore % 60}m` : ''}`
|
||||
: `${minutesBefore}m`;
|
||||
|
||||
const base = `${label} in ${timeStr.trim()}`;
|
||||
return contextMsg ? `${base} — ${contextMsg}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a label matches any context rule.
|
||||
*/
|
||||
export function hasContextMatch(label: string): boolean {
|
||||
return getContextMessage(label) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered context rules (for UI display / editing).
|
||||
*/
|
||||
export function getContextRules(): ContextRule[] {
|
||||
return [...CONTEXT_RULES];
|
||||
}
|
||||
272
web/src/lib/nl-parser.test.ts
Normal file
272
web/src/lib/nl-parser.test.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseNaturalLanguage } from './nl-parser';
|
||||
|
||||
describe('nl-parser', () => {
|
||||
const ONE_MIN = 60_000;
|
||||
const ONE_HOUR = 60 * ONE_MIN;
|
||||
|
||||
describe('relative time (countdown)', () => {
|
||||
it('parses "meeting in 30 minutes"', () => {
|
||||
const result = parseNaturalLanguage('meeting in 30 minutes');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(30 * ONE_MIN);
|
||||
expect(result.timer?.label).toBe('Meeting');
|
||||
expect(result.timer?.urgency).toBe('important'); // "meeting" keyword
|
||||
});
|
||||
|
||||
it('parses "in 5 minutes"', () => {
|
||||
const result = parseNaturalLanguage('in 5 minutes');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(5 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "standup in 30m"', () => {
|
||||
const result = parseNaturalLanguage('standup in 30m');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(30 * ONE_MIN);
|
||||
expect(result.timer?.urgency).toBe('important'); // "standup" keyword
|
||||
});
|
||||
|
||||
it('parses "in 2 hours"', () => {
|
||||
const result = parseNaturalLanguage('in 2 hours');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(2 * ONE_HOUR);
|
||||
});
|
||||
|
||||
it('parses "in 1 hour and 30 minutes"', () => {
|
||||
const result = parseNaturalLanguage('in 1 hour and 30 minutes');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(ONE_HOUR + 30 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "in half an hour"', () => {
|
||||
const result = parseNaturalLanguage('in half an hour');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(30 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "in 90 seconds"', () => {
|
||||
const result = parseNaturalLanguage('in 90 seconds');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(90_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('absolute time (alarm)', () => {
|
||||
it('parses "alarm at 3pm"', () => {
|
||||
const result = parseNaturalLanguage('alarm at 3pm');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('alarm');
|
||||
expect(result.timer?.targetTime).not.toBeNull();
|
||||
// Target should be 3:00 PM today or tomorrow
|
||||
const target = new Date(result.timer!.targetTime!);
|
||||
expect(target.getHours()).toBe(15);
|
||||
expect(target.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "at 3:30 PM"', () => {
|
||||
const result = parseNaturalLanguage('at 3:30 PM');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('alarm');
|
||||
const target = new Date(result.timer!.targetTime!);
|
||||
expect(target.getHours()).toBe(15);
|
||||
expect(target.getMinutes()).toBe(30);
|
||||
});
|
||||
|
||||
it('parses "wake up at 6:30am"', () => {
|
||||
const result = parseNaturalLanguage('wake up at 6:30am');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('alarm');
|
||||
const target = new Date(result.timer!.targetTime!);
|
||||
expect(target.getHours()).toBe(6);
|
||||
expect(target.getMinutes()).toBe(30);
|
||||
expect(result.timer?.label).toBe('Wake up');
|
||||
});
|
||||
|
||||
it('parses "remind me to call Mom at 3pm"', () => {
|
||||
const result = parseNaturalLanguage('remind me to call Mom at 3pm');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('alarm');
|
||||
expect(result.timer?.urgency).toBe('important'); // "call" keyword
|
||||
});
|
||||
|
||||
it('parses "at 12am" (midnight)', () => {
|
||||
const result = parseNaturalLanguage('at 12am');
|
||||
expect(result.success).toBe(true);
|
||||
const target = new Date(result.timer!.targetTime!);
|
||||
expect(target.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "at 12pm" (noon)', () => {
|
||||
const result = parseNaturalLanguage('at 12pm');
|
||||
expect(result.success).toBe(true);
|
||||
const target = new Date(result.timer!.targetTime!);
|
||||
expect(target.getHours()).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration (countdown)', () => {
|
||||
it('parses "30 minutes"', () => {
|
||||
const result = parseNaturalLanguage('30 minutes');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(30 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "1h"', () => {
|
||||
const result = parseNaturalLanguage('1h');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(ONE_HOUR);
|
||||
});
|
||||
|
||||
it('parses "1h 30m"', () => {
|
||||
const result = parseNaturalLanguage('1h 30m');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(ONE_HOUR + 30 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "45s"', () => {
|
||||
const result = parseNaturalLanguage('45s');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(45_000);
|
||||
});
|
||||
|
||||
it('parses "timer for 25 minutes"', () => {
|
||||
const result = parseNaturalLanguage('timer for 25 minutes');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(25 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('parses "half hour"', () => {
|
||||
const result = parseNaturalLanguage('half hour');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('countdown');
|
||||
expect(result.timer?.durationMs).toBe(30 * ONE_MIN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pomodoro', () => {
|
||||
it('parses "pomodoro 4 rounds"', () => {
|
||||
const result = parseNaturalLanguage('pomodoro 4 rounds');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('pomodoro');
|
||||
expect(result.timer?.pomodoroRounds).toBe(4);
|
||||
});
|
||||
|
||||
it('parses "pomodoro"', () => {
|
||||
const result = parseNaturalLanguage('pomodoro');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('pomodoro');
|
||||
expect(result.timer?.pomodoroRounds).toBe(4); // default
|
||||
});
|
||||
|
||||
it('parses "3 pomodoros"', () => {
|
||||
const result = parseNaturalLanguage('3 pomodoros');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('pomodoro');
|
||||
expect(result.timer?.pomodoroRounds).toBe(3);
|
||||
});
|
||||
|
||||
it('parses "focus session"', () => {
|
||||
const result = parseNaturalLanguage('focus session');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.timer?.type).toBe('pomodoro');
|
||||
expect(result.timer?.pomodoroRounds).toBe(4);
|
||||
});
|
||||
|
||||
it('clamps rounds to 1-12', () => {
|
||||
const r1 = parseNaturalLanguage('pomodoro 0 rounds');
|
||||
expect(r1.timer?.pomodoroRounds).toBe(1);
|
||||
const r2 = parseNaturalLanguage('pomodoro 20 rounds');
|
||||
expect(r2.timer?.pomodoroRounds).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urgency detection', () => {
|
||||
it('detects critical urgency from "flight"', () => {
|
||||
const result = parseNaturalLanguage('flight in 2 hours');
|
||||
expect(result.timer?.urgency).toBe('critical');
|
||||
expect(result.timer?.cascade).toBe('aggressive');
|
||||
});
|
||||
|
||||
it('detects important urgency from "meeting"', () => {
|
||||
const result = parseNaturalLanguage('meeting in 30 minutes');
|
||||
expect(result.timer?.urgency).toBe('important');
|
||||
expect(result.timer?.cascade).toBe('standard');
|
||||
});
|
||||
|
||||
it('detects gentle urgency from "check"', () => {
|
||||
const result = parseNaturalLanguage('check laundry in 45 minutes');
|
||||
expect(result.timer?.urgency).toBe('gentle');
|
||||
expect(result.timer?.cascade).toBe('minimal');
|
||||
});
|
||||
|
||||
it('defaults to standard urgency', () => {
|
||||
const result = parseNaturalLanguage('pasta in 10 minutes');
|
||||
expect(result.timer?.urgency).toBe('standard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('label extraction', () => {
|
||||
it('extracts label from "meeting in 30 minutes"', () => {
|
||||
const result = parseNaturalLanguage('meeting in 30 minutes');
|
||||
expect(result.timer?.label).toBe('Meeting');
|
||||
});
|
||||
|
||||
it('extracts label from "dentist appointment at 2pm"', () => {
|
||||
const result = parseNaturalLanguage('dentist appointment at 2pm');
|
||||
expect(result.timer?.label).toMatch(/dentist appointment/i);
|
||||
});
|
||||
|
||||
it('uses default label when no label text remains', () => {
|
||||
const result = parseNaturalLanguage('in 5 minutes');
|
||||
expect(result.timer?.label).toBe('Timer');
|
||||
});
|
||||
|
||||
it('capitalizes first letter', () => {
|
||||
const result = parseNaturalLanguage('pasta timer in 10 minutes');
|
||||
expect(result.timer?.label?.charAt(0)).toBe('P');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns error for empty input', () => {
|
||||
const result = parseNaturalLanguage('');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Empty input');
|
||||
});
|
||||
|
||||
it('returns error for unparseable input', () => {
|
||||
const result = parseNaturalLanguage('hello world');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Could not parse');
|
||||
});
|
||||
|
||||
it('returns error for whitespace-only input', () => {
|
||||
const result = parseNaturalLanguage(' ');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves raw input', () => {
|
||||
const result = parseNaturalLanguage('meeting in 30 minutes');
|
||||
expect(result.timer?.raw).toBe('meeting in 30 minutes');
|
||||
});
|
||||
|
||||
it('has confidence > 0 for successful parses', () => {
|
||||
const result = parseNaturalLanguage('in 5 minutes');
|
||||
expect(result.timer?.confidence).toBeGreaterThan(0);
|
||||
expect(result.timer?.confidence).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
441
web/src/lib/nl-parser.ts
Normal file
441
web/src/lib/nl-parser.ts
Normal file
@ -0,0 +1,441 @@
|
||||
// ── Natural Language Timer Parser ──────────────────────────────
|
||||
// Regex-based parser for natural time expressions. No LLM needed.
|
||||
// Supports: relative times, absolute times, durations, labels, urgency hints, pomodoro.
|
||||
|
||||
import type { UrgencyLevel } from './urgency';
|
||||
import type { CascadePreset } from './cascade';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type ParsedTimerType = 'alarm' | 'countdown' | 'pomodoro';
|
||||
|
||||
export interface ParsedTimer {
|
||||
type: ParsedTimerType;
|
||||
label: string;
|
||||
durationMs: number | null; // for countdown
|
||||
targetTime: number | null; // for alarm (epoch ms)
|
||||
urgency: UrgencyLevel;
|
||||
cascade: CascadePreset;
|
||||
pomodoroRounds: number | null; // for pomodoro
|
||||
confidence: number; // 0-1 how confident the parse is
|
||||
raw: string; // original input
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
timer: ParsedTimer | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ── Urgency Keywords ───────────────────────────────────────────
|
||||
|
||||
const URGENCY_KEYWORDS: Record<string, UrgencyLevel> = {
|
||||
// Critical
|
||||
'critical': 'critical',
|
||||
'urgent': 'critical',
|
||||
'emergency': 'critical',
|
||||
'flight': 'critical',
|
||||
'interview': 'critical',
|
||||
'exam': 'critical',
|
||||
// Important
|
||||
'important': 'important',
|
||||
'meeting': 'important',
|
||||
'appointment': 'important',
|
||||
'doctor': 'important',
|
||||
'dentist': 'important',
|
||||
'standup': 'important',
|
||||
'call': 'important',
|
||||
// Gentle
|
||||
'gentle': 'gentle',
|
||||
'casual': 'gentle',
|
||||
'maybe': 'gentle',
|
||||
'check': 'gentle',
|
||||
// Passive
|
||||
'passive': 'passive',
|
||||
};
|
||||
|
||||
// ── Duration Patterns ──────────────────────────────────────────
|
||||
|
||||
const DURATION_PATTERNS: { pattern: RegExp; extractMs: (match: RegExpMatchArray) => number }[] = [
|
||||
// "30 minutes", "30m", "30 min", "30 mins"
|
||||
{
|
||||
pattern: /(\d+)\s*(?:minutes?|mins?|m\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 60_000,
|
||||
},
|
||||
// "2 hours", "2h", "2 hr", "2hrs"
|
||||
{
|
||||
pattern: /(\d+)\s*(?:hours?|hrs?|h\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 3_600_000,
|
||||
},
|
||||
// "30 seconds", "30s", "30 sec"
|
||||
{
|
||||
pattern: /(\d+)\s*(?:seconds?|secs?|s\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 1_000,
|
||||
},
|
||||
// "1h 30m", "1 hour 30 minutes", "1h30m"
|
||||
{
|
||||
pattern: /(\d+)\s*(?:hours?|hrs?|h)\s*(\d+)\s*(?:minutes?|mins?|m\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 3_600_000 + parseInt(m[2]) * 60_000,
|
||||
},
|
||||
// "half hour", "half an hour"
|
||||
{
|
||||
pattern: /half\s+(?:an?\s+)?hour/i,
|
||||
extractMs: () => 30 * 60_000,
|
||||
},
|
||||
// "quarter hour", "quarter of an hour"
|
||||
{
|
||||
pattern: /quarter\s+(?:of\s+)?(?:an?\s+)?hour/i,
|
||||
extractMs: () => 15 * 60_000,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Time Patterns (absolute) ───────────────────────────────────
|
||||
|
||||
const TIME_PATTERNS: { pattern: RegExp; extractTime: (match: RegExpMatchArray) => { hours: number; minutes: number } | null }[] = [
|
||||
// "3pm", "3:30pm", "3:30 PM", "15:30"
|
||||
{
|
||||
pattern: /(\d{1,2}):(\d{2})\s*(am|pm)/i,
|
||||
extractTime: (m) => {
|
||||
let hours = parseInt(m[1]);
|
||||
const minutes = parseInt(m[2]);
|
||||
const ampm = m[3].toLowerCase();
|
||||
if (ampm === 'pm' && hours !== 12) hours += 12;
|
||||
if (ampm === 'am' && hours === 12) hours = 0;
|
||||
return { hours, minutes };
|
||||
},
|
||||
},
|
||||
// "3pm", "3 pm"
|
||||
{
|
||||
pattern: /(\d{1,2})\s*(am|pm)/i,
|
||||
extractTime: (m) => {
|
||||
let hours = parseInt(m[1]);
|
||||
const ampm = m[2].toLowerCase();
|
||||
if (ampm === 'pm' && hours !== 12) hours += 12;
|
||||
if (ampm === 'am' && hours === 12) hours = 0;
|
||||
return { hours, minutes: 0 };
|
||||
},
|
||||
},
|
||||
// "15:30" (24-hour)
|
||||
{
|
||||
pattern: /(\d{1,2}):(\d{2})(?!\s*(?:am|pm))/i,
|
||||
extractTime: (m) => {
|
||||
const hours = parseInt(m[1]);
|
||||
const minutes = parseInt(m[2]);
|
||||
if (hours > 23 || minutes > 59) return null;
|
||||
return { hours, minutes };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ── Relative Time Patterns ─────────────────────────────────────
|
||||
|
||||
const RELATIVE_PATTERNS: { pattern: RegExp; extractMs: (match: RegExpMatchArray) => number }[] = [
|
||||
// "in 30 minutes", "in 2 hours", "in 30m"
|
||||
{
|
||||
pattern: /in\s+(\d+)\s*(?:hours?|hrs?|h)\s+(?:and\s+)?(\d+)\s*(?:minutes?|mins?|m\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 3_600_000 + parseInt(m[2]) * 60_000,
|
||||
},
|
||||
{
|
||||
pattern: /in\s+(\d+)\s*(?:minutes?|mins?|m\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 60_000,
|
||||
},
|
||||
{
|
||||
pattern: /in\s+(\d+)\s*(?:hours?|hrs?|h\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 3_600_000,
|
||||
},
|
||||
{
|
||||
pattern: /in\s+(\d+)\s*(?:seconds?|secs?|s\b)/i,
|
||||
extractMs: (m) => parseInt(m[1]) * 1_000,
|
||||
},
|
||||
// "in half an hour"
|
||||
{
|
||||
pattern: /in\s+(?:a\s+)?half\s+(?:an?\s+)?hour/i,
|
||||
extractMs: () => 30 * 60_000,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Pomodoro Patterns ──────────────────────────────────────────
|
||||
|
||||
const POMODORO_PATTERNS: { pattern: RegExp; extractRounds: (match: RegExpMatchArray) => number }[] = [
|
||||
// "pomodoro 4 rounds", "pomodoro 4x", "4 pomodoros"
|
||||
{
|
||||
pattern: /pomodoro\s+(\d+)\s*(?:rounds?|x\b|times?)/i,
|
||||
extractRounds: (m) => parseInt(m[1]),
|
||||
},
|
||||
{
|
||||
pattern: /(\d+)\s*(?:pomodoros?|poms?)/i,
|
||||
extractRounds: (m) => parseInt(m[1]),
|
||||
},
|
||||
// just "pomodoro"
|
||||
{
|
||||
pattern: /\bpomodoro\b/i,
|
||||
extractRounds: () => 4,
|
||||
},
|
||||
// "focus session"
|
||||
{
|
||||
pattern: /\bfocus\s+session\b/i,
|
||||
extractRounds: () => 4,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Label Extraction ───────────────────────────────────────────
|
||||
|
||||
const LABEL_STRIP_PATTERNS = [
|
||||
/\bin\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b/gi,
|
||||
/\bin\s+(?:a\s+)?half\s+(?:an?\s+)?hour\b/gi,
|
||||
/\bat\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?\b/gi,
|
||||
/\bfor\s+\d+\s*(?:hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b/gi,
|
||||
/\bpomodoro\s+\d+\s*(?:rounds?|x|times?)\b/gi,
|
||||
/\b\d+\s*(?:pomodoros?|poms?)\b/gi,
|
||||
/\bpomodoro\b/gi,
|
||||
/\bfocus\s+session\b/gi,
|
||||
/\b(?:timer|alarm|reminder|countdown)\b/gi,
|
||||
/\b(?:set|create|start|make|add)\s+(?:a\s+)?/gi,
|
||||
/\b(?:remind\s+me\s+(?:to\s+)?)/gi,
|
||||
/\b(?:in|at|for)\s*$/gi,
|
||||
/\bhalf\s+(?:an?\s+)?hour\b/gi,
|
||||
/\bquarter\s+(?:of\s+)?(?:an?\s+)?hour\b/gi,
|
||||
/\d{1,2}:\d{2}\s*(?:am|pm)?/gi,
|
||||
/\d{1,2}\s*(?:am|pm)/gi,
|
||||
];
|
||||
|
||||
function extractLabel(input: string): string {
|
||||
let label = input.trim();
|
||||
for (const pattern of LABEL_STRIP_PATTERNS) {
|
||||
label = label.replace(pattern, ' ');
|
||||
}
|
||||
// Clean up extra whitespace and punctuation
|
||||
label = label.replace(/\s+/g, ' ').replace(/^[\s,\-—]+|[\s,\-—]+$/g, '').trim();
|
||||
// Capitalize first letter
|
||||
if (label.length > 0) {
|
||||
label = label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// ── Urgency Detection ──────────────────────────────────────────
|
||||
|
||||
function detectUrgency(input: string): UrgencyLevel {
|
||||
const lower = input.toLowerCase();
|
||||
for (const [keyword, level] of Object.entries(URGENCY_KEYWORDS)) {
|
||||
if (lower.includes(keyword)) return level;
|
||||
}
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
// ── Cascade Selection ──────────────────────────────────────────
|
||||
|
||||
function selectCascade(urgency: UrgencyLevel): CascadePreset {
|
||||
switch (urgency) {
|
||||
case 'critical': return 'aggressive';
|
||||
case 'important': return 'standard';
|
||||
case 'standard': return 'standard';
|
||||
case 'gentle': return 'minimal';
|
||||
case 'passive': return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Parser ────────────────────────────────────────────────
|
||||
|
||||
export function parseNaturalLanguage(input: string): ParseResult {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false, timer: null, error: 'Empty input' };
|
||||
}
|
||||
|
||||
const urgency = detectUrgency(trimmed);
|
||||
const cascade = selectCascade(urgency);
|
||||
|
||||
// 1. Try pomodoro patterns first
|
||||
for (const { pattern, extractRounds } of POMODORO_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const rounds = extractRounds(match);
|
||||
const label = extractLabel(trimmed) || 'Focus Session';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'pomodoro',
|
||||
label,
|
||||
durationMs: null,
|
||||
targetTime: null,
|
||||
urgency: 'standard',
|
||||
cascade: 'minimal',
|
||||
pomodoroRounds: Math.min(Math.max(rounds, 1), 12),
|
||||
confidence: 0.9,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try "at <time>" patterns (alarm)
|
||||
const atTimeMatch = trimmed.match(/\bat\s+/i);
|
||||
if (atTimeMatch) {
|
||||
for (const { pattern, extractTime } of TIME_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const time = extractTime(match);
|
||||
if (time) {
|
||||
const now = new Date();
|
||||
const target = new Date();
|
||||
target.setHours(time.hours, time.minutes, 0, 0);
|
||||
if (target.getTime() <= now.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
const label = extractLabel(trimmed) || 'Alarm';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'alarm',
|
||||
label,
|
||||
durationMs: null,
|
||||
targetTime: target.getTime(),
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.95,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try relative time "in X minutes" (countdown)
|
||||
for (const { pattern, extractMs } of RELATIVE_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const ms = extractMs(match);
|
||||
if (ms > 0) {
|
||||
const label = extractLabel(trimmed) || 'Timer';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'countdown',
|
||||
label,
|
||||
durationMs: ms,
|
||||
targetTime: null,
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.9,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try "for X minutes" duration (countdown)
|
||||
const forDurationMatch = trimmed.match(/\bfor\s+/i);
|
||||
if (forDurationMatch) {
|
||||
for (const { pattern, extractMs } of DURATION_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const ms = extractMs(match);
|
||||
if (ms > 0) {
|
||||
const label = extractLabel(trimmed) || 'Timer';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'countdown',
|
||||
label,
|
||||
durationMs: ms,
|
||||
targetTime: null,
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.85,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Try bare duration "30 minutes", "1h", etc. (countdown)
|
||||
// Check compound first (1h 30m), then single
|
||||
const compoundMatch = trimmed.match(/(\d+)\s*(?:hours?|hrs?|h)\s*(\d+)\s*(?:minutes?|mins?|m\b)/i);
|
||||
if (compoundMatch) {
|
||||
const ms = parseInt(compoundMatch[1]) * 3_600_000 + parseInt(compoundMatch[2]) * 60_000;
|
||||
const label = extractLabel(trimmed) || 'Timer';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'countdown',
|
||||
label,
|
||||
durationMs: ms,
|
||||
targetTime: null,
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.8,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const { pattern, extractMs } of DURATION_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const ms = extractMs(match);
|
||||
if (ms > 0) {
|
||||
const label = extractLabel(trimmed) || 'Timer';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'countdown',
|
||||
label,
|
||||
durationMs: ms,
|
||||
targetTime: null,
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.7,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Try bare absolute time without "at" (lower confidence)
|
||||
for (const { pattern, extractTime } of TIME_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match) {
|
||||
const time = extractTime(match);
|
||||
if (time) {
|
||||
const now = new Date();
|
||||
const target = new Date();
|
||||
target.setHours(time.hours, time.minutes, 0, 0);
|
||||
if (target.getTime() <= now.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
const label = extractLabel(trimmed) || 'Alarm';
|
||||
return {
|
||||
success: true,
|
||||
timer: {
|
||||
type: 'alarm',
|
||||
label,
|
||||
durationMs: null,
|
||||
targetTime: target.getTime(),
|
||||
urgency,
|
||||
cascade,
|
||||
pomodoroRounds: null,
|
||||
confidence: 0.6,
|
||||
raw: trimmed,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
timer: null,
|
||||
error: `Could not parse: "${trimmed}". Try "meeting in 30 minutes" or "alarm at 3pm".`,
|
||||
};
|
||||
}
|
||||
393
web/src/lib/routines.test.ts
Normal file
393
web/src/lib/routines.test.ts
Normal file
@ -0,0 +1,393 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createRoutine,
|
||||
createRoutineStep,
|
||||
startRoutine,
|
||||
pauseRoutine,
|
||||
resumeRoutine,
|
||||
completeCurrentStep,
|
||||
skipCurrentStep,
|
||||
cancelRoutine,
|
||||
getCurrentStep,
|
||||
getNextStep,
|
||||
getCompletedStepCount,
|
||||
getSkippedStepCount,
|
||||
getRoutineProgress,
|
||||
getRemainingStepMs,
|
||||
shouldStepComplete,
|
||||
calculateTotalDuration,
|
||||
getTransitionMinutes,
|
||||
instantiateTemplate,
|
||||
getBuiltInTemplates,
|
||||
ROUTINE_TEMPLATES,
|
||||
} from './routines';
|
||||
|
||||
describe('routines', () => {
|
||||
const ONE_MIN = 60_000;
|
||||
|
||||
function makeSteps() {
|
||||
return [
|
||||
{ label: 'Step A', durationMinutes: 5, transition: 'immediate' as const },
|
||||
{ label: 'Step B', durationMinutes: 10, transition: '1m_break' as const },
|
||||
{ label: 'Step C', durationMinutes: 15, transition: 'immediate' as const },
|
||||
];
|
||||
}
|
||||
|
||||
describe('createRoutine', () => {
|
||||
it('creates a routine with correct fields', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
expect(routine.name).toBe('Test');
|
||||
expect(routine.status).toBe('ready');
|
||||
expect(routine.steps).toHaveLength(3);
|
||||
expect(routine.currentStepIndex).toBe(0);
|
||||
expect(routine.isTemplate).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a template routine', () => {
|
||||
const routine = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true });
|
||||
expect(routine.status).toBe('template');
|
||||
expect(routine.isTemplate).toBe(true);
|
||||
});
|
||||
|
||||
it('generates unique IDs for steps', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
const ids = routine.steps.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it('initializes all steps as pending', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
routine.steps.forEach((step) => {
|
||||
expect(step.status).toBe('pending');
|
||||
expect(step.startedAt).toBeNull();
|
||||
expect(step.completedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTotalDuration', () => {
|
||||
it('sums step durations and transitions', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
// Step A: 5m, Step B: 10m + 1m transition from A, Step C: 15m + 0 (last has no transition counted)
|
||||
// Actually: A=5 + transition(0), B=10 + transition(1m from B, but only between steps), C=15
|
||||
// calculateTotalDuration: step.duration + transition if not last
|
||||
// A: 5 + 0(immediate) = 5, B: 10 + 1(1m_break) = 11, C: 15 + 0(last) = 15 => 31
|
||||
expect(routine.totalDurationMinutes).toBe(31);
|
||||
});
|
||||
|
||||
it('returns 0 for empty steps', () => {
|
||||
expect(calculateTotalDuration([])).toBe(0);
|
||||
});
|
||||
|
||||
it('handles custom transitions', () => {
|
||||
const steps = [
|
||||
createRoutineStep({ label: 'A', durationMinutes: 10, transition: 'custom', customTransitionMinutes: 3 }),
|
||||
createRoutineStep({ label: 'B', durationMinutes: 5, transition: 'immediate' }),
|
||||
];
|
||||
expect(calculateTotalDuration(steps)).toBe(10 + 3 + 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTransitionMinutes', () => {
|
||||
it('returns 0 for immediate', () => {
|
||||
const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: 'immediate' });
|
||||
expect(getTransitionMinutes(step)).toBe(0);
|
||||
});
|
||||
it('returns 1 for 1m_break', () => {
|
||||
const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: '1m_break' });
|
||||
expect(getTransitionMinutes(step)).toBe(1);
|
||||
});
|
||||
it('returns 5 for 5m_break', () => {
|
||||
const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: '5m_break' });
|
||||
expect(getTransitionMinutes(step)).toBe(5);
|
||||
});
|
||||
it('returns custom value', () => {
|
||||
const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: 'custom', customTransitionMinutes: 7 });
|
||||
expect(getTransitionMinutes(step)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('state machine', () => {
|
||||
it('ready -> active via startRoutine', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
const started = startRoutine(routine);
|
||||
expect(started.status).toBe('active');
|
||||
expect(started.startedAt).not.toBeNull();
|
||||
expect(started.steps[0].status).toBe('active');
|
||||
expect(started.steps[0].startedAt).not.toBeNull();
|
||||
expect(started.steps[1].status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does not start empty routine', () => {
|
||||
const routine = createRoutine({ name: 'Empty', steps: [] });
|
||||
const result = startRoutine(routine);
|
||||
expect(result.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('active -> paused', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const paused = pauseRoutine(routine);
|
||||
expect(paused.status).toBe('paused');
|
||||
expect(paused.pausedAt).not.toBeNull();
|
||||
expect(paused.elapsedBeforePause).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('paused -> active via resumeRoutine', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
routine = pauseRoutine(routine);
|
||||
const resumed = resumeRoutine(routine);
|
||||
expect(resumed.status).toBe('active');
|
||||
expect(resumed.pausedAt).toBeNull();
|
||||
expect(resumed.steps[0].startedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not pause a non-active routine', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
expect(pauseRoutine(routine).status).toBe('ready');
|
||||
});
|
||||
|
||||
it('does not resume a non-paused routine', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(resumeRoutine(routine).status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step transitions', () => {
|
||||
it('completes current step and advances', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const next = completeCurrentStep(routine);
|
||||
expect(next.steps[0].status).toBe('completed');
|
||||
expect(next.steps[0].completedAt).not.toBeNull();
|
||||
expect(next.steps[1].status).toBe('active');
|
||||
expect(next.steps[1].startedAt).not.toBeNull();
|
||||
expect(next.currentStepIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('completes routine on last step', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
routine = completeCurrentStep(routine); // A -> B
|
||||
routine = completeCurrentStep(routine); // B -> C
|
||||
routine = completeCurrentStep(routine); // C -> done
|
||||
expect(routine.status).toBe('completed');
|
||||
expect(routine.completedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('skips current step and advances', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const next = skipCurrentStep(routine);
|
||||
expect(next.steps[0].status).toBe('skipped');
|
||||
expect(next.steps[1].status).toBe('active');
|
||||
expect(next.currentStepIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('skipping last step completes routine', () => {
|
||||
let routine = startRoutine(createRoutine({
|
||||
name: 'Single',
|
||||
steps: [{ label: 'Only Step', durationMinutes: 5, transition: 'immediate' as const }],
|
||||
}));
|
||||
routine = skipCurrentStep(routine);
|
||||
expect(routine.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('does not complete step on non-active routine', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
const result = completeCurrentStep(routine);
|
||||
expect(result.currentStepIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelRoutine', () => {
|
||||
it('cancels an active routine', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const cancelled = cancelRoutine(routine);
|
||||
expect(cancelled.status).toBe('cancelled');
|
||||
expect(cancelled.completedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not cancel a completed routine', () => {
|
||||
let routine = startRoutine(createRoutine({
|
||||
name: 'Single',
|
||||
steps: [{ label: 'Step', durationMinutes: 1, transition: 'immediate' as const }],
|
||||
}));
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(cancelRoutine(routine).status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('utility functions', () => {
|
||||
it('getCurrentStep returns active step', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(getCurrentStep(routine)?.label).toBe('Step A');
|
||||
});
|
||||
|
||||
it('getNextStep returns the next step', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(getNextStep(routine)?.label).toBe('Step B');
|
||||
});
|
||||
|
||||
it('getNextStep returns null on last step', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
routine = completeCurrentStep(routine);
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getNextStep(routine)).toBeNull();
|
||||
});
|
||||
|
||||
it('getCompletedStepCount counts completed steps', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(getCompletedStepCount(routine)).toBe(0);
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getCompletedStepCount(routine)).toBe(1);
|
||||
});
|
||||
|
||||
it('getSkippedStepCount counts skipped steps', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
routine = skipCurrentStep(routine);
|
||||
expect(getSkippedStepCount(routine)).toBe(1);
|
||||
});
|
||||
|
||||
it('getRoutineProgress returns fraction', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(getRoutineProgress(routine)).toBe(0);
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getRoutineProgress(routine)).toBeCloseTo(1 / 3);
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getRoutineProgress(routine)).toBeCloseTo(2 / 3);
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getRoutineProgress(routine)).toBe(1);
|
||||
});
|
||||
|
||||
it('getRoutineProgress returns 0 for empty', () => {
|
||||
const routine = createRoutine({ name: 'Empty', steps: [] });
|
||||
expect(getRoutineProgress(routine)).toBe(0);
|
||||
});
|
||||
|
||||
it('getRemainingStepMs returns remaining time for active step', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const remaining = getRemainingStepMs(routine);
|
||||
expect(remaining).toBeGreaterThan(4.9 * ONE_MIN);
|
||||
expect(remaining).toBeLessThanOrEqual(5 * ONE_MIN);
|
||||
});
|
||||
|
||||
it('getRemainingStepMs returns remaining for paused routine', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
routine = pauseRoutine(routine);
|
||||
const remaining = getRemainingStepMs(routine);
|
||||
expect(remaining).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('getRemainingStepMs returns 0 for non-active routine', () => {
|
||||
const routine = createRoutine({ name: 'Test', steps: makeSteps() });
|
||||
expect(getRemainingStepMs(routine)).toBe(0);
|
||||
});
|
||||
|
||||
it('shouldStepComplete returns true when time is up', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
const futureTime = Date.now() + 6 * ONE_MIN;
|
||||
expect(shouldStepComplete(routine, futureTime)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldStepComplete returns false when time remains', () => {
|
||||
const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() }));
|
||||
expect(shouldStepComplete(routine)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('instantiateTemplate', () => {
|
||||
it('creates a ready copy from a template', () => {
|
||||
const template = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true });
|
||||
const instance = instantiateTemplate(template);
|
||||
expect(instance.id).not.toBe(template.id);
|
||||
expect(instance.status).toBe('ready');
|
||||
expect(instance.isTemplate).toBe(false);
|
||||
expect(instance.steps).toHaveLength(3);
|
||||
expect(instance.steps[0].id).not.toBe(template.steps[0].id);
|
||||
});
|
||||
|
||||
it('preserves step labels and durations', () => {
|
||||
const template = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true });
|
||||
const instance = instantiateTemplate(template);
|
||||
expect(instance.steps[0].label).toBe('Step A');
|
||||
expect(instance.steps[0].durationMinutes).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('built-in templates', () => {
|
||||
it('has 4 templates', () => {
|
||||
expect(ROUTINE_TEMPLATES).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('getBuiltInTemplates returns valid routines', () => {
|
||||
const templates = getBuiltInTemplates();
|
||||
expect(templates).toHaveLength(4);
|
||||
templates.forEach((t) => {
|
||||
expect(t.isTemplate).toBe(true);
|
||||
expect(t.status).toBe('template');
|
||||
expect(t.steps.length).toBeGreaterThan(0);
|
||||
expect(t.totalDurationMinutes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('Morning Routine has 5 steps', () => {
|
||||
const templates = getBuiltInTemplates();
|
||||
const morning = templates.find((t) => t.name === 'Morning Routine');
|
||||
expect(morning).toBeDefined();
|
||||
expect(morning!.steps).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('templates can be instantiated and started', () => {
|
||||
const templates = getBuiltInTemplates();
|
||||
const instance = instantiateTemplate(templates[0]);
|
||||
const started = startRoutine(instance);
|
||||
expect(started.status).toBe('active');
|
||||
expect(started.steps[0].status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('full routine lifecycle', () => {
|
||||
it('runs through all steps to completion', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Full Test', steps: makeSteps() }));
|
||||
expect(routine.status).toBe('active');
|
||||
expect(getCurrentStep(routine)?.label).toBe('Step A');
|
||||
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getCurrentStep(routine)?.label).toBe('Step B');
|
||||
expect(getRoutineProgress(routine)).toBeCloseTo(1 / 3);
|
||||
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(getCurrentStep(routine)?.label).toBe('Step C');
|
||||
expect(getRoutineProgress(routine)).toBeCloseTo(2 / 3);
|
||||
|
||||
routine = completeCurrentStep(routine);
|
||||
expect(routine.status).toBe('completed');
|
||||
expect(getRoutineProgress(routine)).toBe(1);
|
||||
expect(getCompletedStepCount(routine)).toBe(3);
|
||||
expect(getSkippedStepCount(routine)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles mixed complete and skip', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Mixed', steps: makeSteps() }));
|
||||
routine = completeCurrentStep(routine); // complete A
|
||||
routine = skipCurrentStep(routine); // skip B
|
||||
routine = completeCurrentStep(routine); // complete C
|
||||
expect(routine.status).toBe('completed');
|
||||
expect(getCompletedStepCount(routine)).toBe(2);
|
||||
expect(getSkippedStepCount(routine)).toBe(1);
|
||||
});
|
||||
|
||||
it('pause and resume preserves state', () => {
|
||||
let routine = startRoutine(createRoutine({ name: 'Pause Test', steps: makeSteps() }));
|
||||
routine = completeCurrentStep(routine); // A done, B active
|
||||
routine = pauseRoutine(routine);
|
||||
expect(routine.status).toBe('paused');
|
||||
expect(routine.currentStepIndex).toBe(1);
|
||||
|
||||
routine = resumeRoutine(routine);
|
||||
expect(routine.status).toBe('active');
|
||||
expect(getCurrentStep(routine)?.label).toBe('Step B');
|
||||
|
||||
routine = completeCurrentStep(routine); // B done, C active
|
||||
routine = completeCurrentStep(routine); // C done
|
||||
expect(routine.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
394
web/src/lib/routines.ts
Normal file
394
web/src/lib/routines.ts
Normal file
@ -0,0 +1,394 @@
|
||||
// ── Routine Engine ────────────────────────────────────────────
|
||||
// Ordered sequences of timed steps with transitions, state machine, and templates
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type TransitionType = 'immediate' | '1m_break' | '5m_break' | 'custom';
|
||||
|
||||
export type RoutineStatus = 'template' | 'ready' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'skipped' | 'completed';
|
||||
|
||||
export interface RoutineStep {
|
||||
id: string;
|
||||
label: string;
|
||||
durationMinutes: number;
|
||||
transition: TransitionType;
|
||||
customTransitionMinutes?: number; // only if transition === 'custom'
|
||||
notes?: string;
|
||||
status: StepStatus;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
}
|
||||
|
||||
export interface Routine {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: RoutineStep[];
|
||||
totalDurationMinutes: number; // auto-calculated including transitions
|
||||
status: RoutineStatus;
|
||||
currentStepIndex: number;
|
||||
createdAt: number;
|
||||
startedAt: number | null;
|
||||
pausedAt: number | null;
|
||||
completedAt: number | null;
|
||||
elapsedBeforePause: number; // ms accumulated for current step before last pause
|
||||
isTemplate: boolean;
|
||||
}
|
||||
|
||||
// ── Transition Helpers ─────────────────────────────────────────
|
||||
|
||||
export const TRANSITION_LABELS: Record<TransitionType, string> = {
|
||||
immediate: 'Immediate',
|
||||
'1m_break': '1 min break',
|
||||
'5m_break': '5 min break',
|
||||
custom: 'Custom break',
|
||||
};
|
||||
|
||||
export function getTransitionMinutes(step: RoutineStep): number {
|
||||
switch (step.transition) {
|
||||
case 'immediate': return 0;
|
||||
case '1m_break': return 1;
|
||||
case '5m_break': return 5;
|
||||
case 'custom': return step.customTransitionMinutes ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Factory Functions ──────────────────────────────────────────
|
||||
|
||||
export interface CreateRoutineParams {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Omit<RoutineStep, 'id' | 'status' | 'startedAt' | 'completedAt'>[];
|
||||
isTemplate?: boolean;
|
||||
}
|
||||
|
||||
export function createRoutineStep(
|
||||
params: Omit<RoutineStep, 'id' | 'status' | 'startedAt' | 'completedAt'>
|
||||
): RoutineStep {
|
||||
return {
|
||||
...params,
|
||||
id: uuidv4(),
|
||||
status: 'pending',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRoutine(params: CreateRoutineParams): Routine {
|
||||
const steps = params.steps.map(createRoutineStep);
|
||||
const totalDurationMinutes = calculateTotalDuration(steps);
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
steps,
|
||||
totalDurationMinutes,
|
||||
status: params.isTemplate ? 'template' : 'ready',
|
||||
currentStepIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
completedAt: null,
|
||||
elapsedBeforePause: 0,
|
||||
isTemplate: params.isTemplate ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateTotalDuration(steps: RoutineStep[]): number {
|
||||
return steps.reduce((total, step, idx) => {
|
||||
const transition = idx < steps.length - 1 ? getTransitionMinutes(step) : 0;
|
||||
return total + step.durationMinutes + transition;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// ── State Machine ──────────────────────────────────────────────
|
||||
|
||||
export function startRoutine(routine: Routine): Routine {
|
||||
if (routine.status !== 'ready' && routine.status !== 'template') return routine;
|
||||
if (routine.steps.length === 0) return routine;
|
||||
|
||||
const now = Date.now();
|
||||
const steps = routine.steps.map((step, idx) => ({
|
||||
...step,
|
||||
status: (idx === 0 ? 'active' : 'pending') as StepStatus,
|
||||
startedAt: idx === 0 ? now : null,
|
||||
completedAt: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
...routine,
|
||||
steps,
|
||||
status: 'active',
|
||||
currentStepIndex: 0,
|
||||
startedAt: now,
|
||||
pausedAt: null,
|
||||
completedAt: null,
|
||||
elapsedBeforePause: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function pauseRoutine(routine: Routine): Routine {
|
||||
if (routine.status !== 'active') return routine;
|
||||
const now = Date.now();
|
||||
const currentStep = routine.steps[routine.currentStepIndex];
|
||||
const elapsed = routine.elapsedBeforePause + (now - (currentStep?.startedAt ?? now));
|
||||
|
||||
return {
|
||||
...routine,
|
||||
status: 'paused',
|
||||
pausedAt: now,
|
||||
elapsedBeforePause: elapsed,
|
||||
};
|
||||
}
|
||||
|
||||
export function resumeRoutine(routine: Routine): Routine {
|
||||
if (routine.status !== 'paused') return routine;
|
||||
const now = Date.now();
|
||||
const steps = routine.steps.map((step, idx) => {
|
||||
if (idx === routine.currentStepIndex) {
|
||||
return { ...step, startedAt: now };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
return {
|
||||
...routine,
|
||||
steps,
|
||||
status: 'active',
|
||||
pausedAt: null,
|
||||
// elapsedBeforePause stays as-is — resume continues from where we paused
|
||||
};
|
||||
}
|
||||
|
||||
export function completeCurrentStep(routine: Routine): Routine {
|
||||
if (routine.status !== 'active') return routine;
|
||||
const { currentStepIndex, steps } = routine;
|
||||
if (currentStepIndex >= steps.length) return routine;
|
||||
|
||||
const now = Date.now();
|
||||
const updatedSteps = steps.map((step, idx) => {
|
||||
if (idx === currentStepIndex) {
|
||||
return { ...step, status: 'completed' as StepStatus, completedAt: now };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
// Check if this was the last step
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= steps.length) {
|
||||
return {
|
||||
...routine,
|
||||
steps: updatedSteps,
|
||||
status: 'completed',
|
||||
completedAt: now,
|
||||
currentStepIndex: nextIndex,
|
||||
elapsedBeforePause: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Advance to next step
|
||||
const nextSteps = updatedSteps.map((step, idx) => {
|
||||
if (idx === nextIndex) {
|
||||
return { ...step, status: 'active' as StepStatus, startedAt: now };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
return {
|
||||
...routine,
|
||||
steps: nextSteps,
|
||||
currentStepIndex: nextIndex,
|
||||
elapsedBeforePause: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function skipCurrentStep(routine: Routine): Routine {
|
||||
if (routine.status !== 'active') return routine;
|
||||
const { currentStepIndex, steps } = routine;
|
||||
if (currentStepIndex >= steps.length) return routine;
|
||||
|
||||
const now = Date.now();
|
||||
const updatedSteps = steps.map((step, idx) => {
|
||||
if (idx === currentStepIndex) {
|
||||
return { ...step, status: 'skipped' as StepStatus, completedAt: now };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
// Check if this was the last step
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex >= steps.length) {
|
||||
return {
|
||||
...routine,
|
||||
steps: updatedSteps,
|
||||
status: 'completed',
|
||||
completedAt: now,
|
||||
currentStepIndex: nextIndex,
|
||||
elapsedBeforePause: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Advance to next step
|
||||
const nextSteps = updatedSteps.map((step, idx) => {
|
||||
if (idx === nextIndex) {
|
||||
return { ...step, status: 'active' as StepStatus, startedAt: now };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
return {
|
||||
...routine,
|
||||
steps: nextSteps,
|
||||
currentStepIndex: nextIndex,
|
||||
elapsedBeforePause: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function cancelRoutine(routine: Routine): Routine {
|
||||
if (routine.status === 'completed' || routine.status === 'cancelled') return routine;
|
||||
return {
|
||||
...routine,
|
||||
status: 'cancelled',
|
||||
completedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Utility ────────────────────────────────────────────────────
|
||||
|
||||
export function getCurrentStep(routine: Routine): RoutineStep | null {
|
||||
if (routine.currentStepIndex >= routine.steps.length) return null;
|
||||
return routine.steps[routine.currentStepIndex];
|
||||
}
|
||||
|
||||
export function getNextStep(routine: Routine): RoutineStep | null {
|
||||
const nextIdx = routine.currentStepIndex + 1;
|
||||
if (nextIdx >= routine.steps.length) return null;
|
||||
return routine.steps[nextIdx];
|
||||
}
|
||||
|
||||
export function getCompletedStepCount(routine: Routine): number {
|
||||
return routine.steps.filter((s) => s.status === 'completed').length;
|
||||
}
|
||||
|
||||
export function getSkippedStepCount(routine: Routine): number {
|
||||
return routine.steps.filter((s) => s.status === 'skipped').length;
|
||||
}
|
||||
|
||||
export function getRoutineProgress(routine: Routine): number {
|
||||
if (routine.steps.length === 0) return 0;
|
||||
const done = routine.steps.filter((s) => s.status === 'completed' || s.status === 'skipped').length;
|
||||
return done / routine.steps.length;
|
||||
}
|
||||
|
||||
export function getRemainingStepMs(routine: Routine, now: number = Date.now()): number {
|
||||
if (routine.status === 'paused') {
|
||||
const currentStep = routine.steps[routine.currentStepIndex];
|
||||
if (!currentStep) return 0;
|
||||
return currentStep.durationMinutes * 60_000 - routine.elapsedBeforePause;
|
||||
}
|
||||
if (routine.status !== 'active') return 0;
|
||||
const currentStep = routine.steps[routine.currentStepIndex];
|
||||
if (!currentStep || !currentStep.startedAt) return 0;
|
||||
const elapsed = routine.elapsedBeforePause + (now - currentStep.startedAt);
|
||||
return Math.max(0, currentStep.durationMinutes * 60_000 - elapsed);
|
||||
}
|
||||
|
||||
export function shouldStepComplete(routine: Routine, now: number = Date.now()): boolean {
|
||||
return routine.status === 'active' && getRemainingStepMs(routine, now) <= 0;
|
||||
}
|
||||
|
||||
export function instantiateTemplate(template: Routine): Routine {
|
||||
if (!template.isTemplate && template.status !== 'template') return template;
|
||||
const steps = template.steps.map((step) => createRoutineStep({
|
||||
label: step.label,
|
||||
durationMinutes: step.durationMinutes,
|
||||
transition: step.transition,
|
||||
customTransitionMinutes: step.customTransitionMinutes,
|
||||
notes: step.notes,
|
||||
}));
|
||||
|
||||
return {
|
||||
...template,
|
||||
id: uuidv4(),
|
||||
steps,
|
||||
status: 'ready',
|
||||
currentStepIndex: 0,
|
||||
createdAt: Date.now(),
|
||||
startedAt: null,
|
||||
pausedAt: null,
|
||||
completedAt: null,
|
||||
elapsedBeforePause: 0,
|
||||
isTemplate: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Built-in Templates ─────────────────────────────────────────
|
||||
|
||||
function makeTemplateStep(
|
||||
label: string,
|
||||
durationMinutes: number,
|
||||
transition: TransitionType = 'immediate',
|
||||
notes?: string
|
||||
): Omit<RoutineStep, 'id' | 'status' | 'startedAt' | 'completedAt'> {
|
||||
return { label, durationMinutes, transition, notes };
|
||||
}
|
||||
|
||||
export const ROUTINE_TEMPLATES: CreateRoutineParams[] = [
|
||||
{
|
||||
name: 'Morning Routine',
|
||||
description: 'Start your day with intention',
|
||||
isTemplate: true,
|
||||
steps: [
|
||||
makeTemplateStep('Wake Up + Hydrate', 5, 'immediate', 'Drink a glass of water'),
|
||||
makeTemplateStep('Meditation', 15, '1m_break', 'Mindfulness or breathing exercise'),
|
||||
makeTemplateStep('Exercise', 30, '1m_break', 'Workout, yoga, or a walk'),
|
||||
makeTemplateStep('Shower + Get Ready', 20, 'immediate'),
|
||||
makeTemplateStep('Breakfast', 15, 'immediate'),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Workout',
|
||||
description: 'Structured workout with warm-up and cool-down',
|
||||
isTemplate: true,
|
||||
steps: [
|
||||
makeTemplateStep('Warm Up', 5, 'immediate', 'Light stretching and mobility'),
|
||||
makeTemplateStep('Main Workout', 30, '1m_break', 'Strength or cardio'),
|
||||
makeTemplateStep('Cool Down', 5, 'immediate', 'Stretching and foam rolling'),
|
||||
makeTemplateStep('Hydrate + Recovery', 5, 'immediate'),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cooking Prep',
|
||||
description: 'Organized meal preparation',
|
||||
isTemplate: true,
|
||||
steps: [
|
||||
makeTemplateStep('Gather Ingredients', 5, 'immediate', 'Get everything out'),
|
||||
makeTemplateStep('Prep & Chop', 15, 'immediate', 'Wash, peel, and chop'),
|
||||
makeTemplateStep('Cook', 25, '5m_break', 'Follow the recipe'),
|
||||
makeTemplateStep('Plate & Serve', 5, 'immediate'),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Evening Wind-Down',
|
||||
description: 'Prepare your mind and body for rest',
|
||||
isTemplate: true,
|
||||
steps: [
|
||||
makeTemplateStep('Screen-Free Time', 15, 'immediate', 'Put devices away'),
|
||||
makeTemplateStep('Light Stretching', 10, '1m_break', 'Gentle yoga or stretching'),
|
||||
makeTemplateStep('Journal / Reflect', 10, 'immediate', 'Write about your day'),
|
||||
makeTemplateStep('Read', 20, 'immediate', 'Read a book or magazine'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all built-in templates as Routine objects.
|
||||
*/
|
||||
export function getBuiltInTemplates(): Routine[] {
|
||||
return ROUTINE_TEMPLATES.map(createRoutine);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user