From 8fe5e8e787bfe9c405c49795d228159193e5a201 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 21:42:29 -0800 Subject: [PATCH] feat(web): add routine engine, NL parser, and context messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web/src/lib/context-messages.test.ts | 127 ++++++++ web/src/lib/context-messages.ts | 171 +++++++++++ web/src/lib/nl-parser.test.ts | 272 +++++++++++++++++ web/src/lib/nl-parser.ts | 441 +++++++++++++++++++++++++++ web/src/lib/routines.test.ts | 393 ++++++++++++++++++++++++ web/src/lib/routines.ts | 394 ++++++++++++++++++++++++ 6 files changed, 1798 insertions(+) create mode 100644 web/src/lib/context-messages.test.ts create mode 100644 web/src/lib/context-messages.ts create mode 100644 web/src/lib/nl-parser.test.ts create mode 100644 web/src/lib/nl-parser.ts create mode 100644 web/src/lib/routines.test.ts create mode 100644 web/src/lib/routines.ts diff --git a/web/src/lib/context-messages.test.ts b/web/src/lib/context-messages.test.ts new file mode 100644 index 0000000..5c7f282 --- /dev/null +++ b/web/src/lib/context-messages.test.ts @@ -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); + }); + }); +}); diff --git a/web/src/lib/context-messages.ts b/web/src/lib/context-messages.ts new file mode 100644 index 0000000..8e93215 --- /dev/null +++ b/web/src/lib/context-messages.ts @@ -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]; +} diff --git a/web/src/lib/nl-parser.test.ts b/web/src/lib/nl-parser.test.ts new file mode 100644 index 0000000..1a914d6 --- /dev/null +++ b/web/src/lib/nl-parser.test.ts @@ -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); + }); + }); +}); diff --git a/web/src/lib/nl-parser.ts b/web/src/lib/nl-parser.ts new file mode 100644 index 0000000..940dea4 --- /dev/null +++ b/web/src/lib/nl-parser.ts @@ -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 = { + // 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