diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx index 5f9c632..6833bea 100644 --- a/web/src/components/CreateTimerModal.tsx +++ b/web/src/components/CreateTimerModal.tsx @@ -6,12 +6,12 @@ import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency'; import type { UrgencyLevel } from '@/lib/urgency'; import { CASCADE_PRESET_LABELS } from '@/lib/cascade'; import type { CascadePreset } from '@/lib/cascade'; -import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react'; +import { X, AlarmClock, Timer, Coffee, Sparkles, CalendarDays } from 'lucide-react'; import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories'; import { parseNaturalLanguage } from '@/lib/nl-parser'; import type { ParseResult } from '@/lib/nl-parser'; -type TabType = 'alarm' | 'countdown' | 'pomodoro'; +type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event'; interface CreateTimerModalProps { isOpen: boolean; @@ -19,7 +19,7 @@ interface CreateTimerModalProps { } export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { - const { addAlarm, addCountdown, addPomodoro } = useTimerStore(); + const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore(); const [tab, setTab] = useState('countdown'); const [nlInput, setNlInput] = useState(''); @@ -43,6 +43,9 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { const [longBreakMin, setLongBreakMin] = useState(15); const [rounds, setRounds] = useState(4); + // Event fields + const [eventDate, setEventDate] = useState(''); + if (!isOpen) return null; const handleNlChange = (value: string) => { @@ -136,6 +139,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { }, urgency, }); + } else if (tab === 'event') { + if (!eventDate) return; + const target = new Date(eventDate).getTime(); + if (target <= Date.now()) return; + addEvent({ + label: label || 'Event Countdown', + targetTime: target, + urgency, + category: catOrUndef, + }); } // Reset form @@ -145,6 +158,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { setMinutes(25); setSeconds(0); setCategory(''); + setEventDate(''); onClose(); }; @@ -152,6 +166,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { { key: 'countdown', label: 'Countdown', icon: }, { key: 'alarm', label: 'Alarm', icon: }, { key: 'pomodoro', label: 'Pomodoro', icon: }, + { key: 'event', label: 'Event', icon: }, ]; return ( @@ -373,6 +388,31 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { )} + {tab === 'event' && ( +
+ + setEventDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> + {eventDate && new Date(eventDate).getTime() > Date.now() && ( +

+ {Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now · Milestone warnings at 30, 7, 3, 1 days +

+ )} +
+ )} + {/* Category */} {tab !== 'pomodoro' && (
diff --git a/web/src/components/TimerCard.tsx b/web/src/components/TimerCard.tsx index 39d5a92..3767467 100644 --- a/web/src/components/TimerCard.tsx +++ b/web/src/components/TimerCard.tsx @@ -16,8 +16,12 @@ import { Coffee, Bell, BellOff, + Repeat, + Link2, + Tag, } from 'lucide-react'; import { getTimeReferenceMs } from '@/lib/time-blindness'; +import { getSuggestedPrepTime, shouldShowPrepWarning, formatPrepWarning } from '@/lib/prep-time'; interface TimerCardProps { timer: Timer; @@ -91,6 +95,23 @@ export function TimerCard({ timer }: TimerCardProps) { > {urgencyConfig.label} + {timer.category && ( + + {timer.category} + + )} + {timer.linkedTimerId && ( + + + + )}
)} + {/* Prep time warning */} + {!isDone && !isFiring && (() => { + const prepConfig = getSuggestedPrepTime(timer.label, timer.category); + const minutesUntil = Math.round(remaining / 60_000); + if (shouldShowPrepWarning(timer.targetTime, prepConfig, now)) { + return ( +

+ ⏱ {formatPrepWarning(prepConfig, minutesUntil)} +

+ ); + } + return null; + })()} + {/* Snooze info */} {timer.snoozeCount > 0 && (

diff --git a/web/src/lib/adaptive-snooze.test.ts b/web/src/lib/adaptive-snooze.test.ts new file mode 100644 index 0000000..2eea580 --- /dev/null +++ b/web/src/lib/adaptive-snooze.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + getSnoozeHistory, + saveSnoozeHistory, + clearSnoozeHistory, + recordSnooze, + normalizeLabel, + getSnoozeStats, + getSnoozeSuggestions, + checkForSnoozeSuggestion, +} from './adaptive-snooze'; +import type { SnoozeRecord } from './adaptive-snooze'; +import type { Timer } from './timer-engine'; + +// Mock localStorage +const store: Record = {}; +beforeEach(() => { + Object.keys(store).forEach((k) => delete store[k]); + vi.stubGlobal('localStorage', { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + }); +}); + +function makeTimer(overrides: Partial = {}): Timer { + const now = Date.now(); + return { + id: 'test-1', + type: 'alarm', + label: 'Morning Alarm', + urgency: 'standard', + state: 'dismissed', + targetTime: now, + duration: null, + createdAt: now - 3600_000, + startedAt: now - 3600_000, + pausedAt: null, + firedAt: now - 900_000, + dismissedAt: now, + completedAt: null, + elapsedBeforePause: 0, + cascade: { preset: 'standard', intervals: [] }, + warnings: [], + snoozeCount: 3, + snoozedUntil: null, + ...overrides, + }; +} + +function makeRecords(label: string, count: number, snoozeCount: number = 3): SnoozeRecord[] { + return Array.from({ length: count }, (_, i) => ({ + timerId: `timer-${i}`, + label, + snoozeCount, + totalSnoozeMinutes: snoozeCount * 5, + timestamp: Date.now() - i * 86400_000, + })); +} + +describe('normalizeLabel', () => { + it('lowercases and trims', () => { + expect(normalizeLabel(' Morning Alarm ')).toBe('morning alarm'); + }); + + it('removes trailing names after "meeting with"', () => { + expect(normalizeLabel('Meeting with Bob')).toBe('meeting with'); + expect(normalizeLabel('Meeting with Alice Smith')).toBe('meeting with'); + }); + + it('removes trailing numbers from alarm/timer', () => { + expect(normalizeLabel('Alarm #3')).toBe('alarm'); + expect(normalizeLabel('Timer 5')).toBe('timer'); + }); + + it('removes embedded times', () => { + expect(normalizeLabel('Reminder 3:30 PM')).toBe('reminder'); + }); + + it('collapses whitespace', () => { + expect(normalizeLabel('some big label')).toBe('some big label'); + }); +}); + +describe('persistence', () => { + it('saves and retrieves history', () => { + const records = makeRecords('Alarm', 3); + saveSnoozeHistory(records); + expect(getSnoozeHistory()).toHaveLength(3); + }); + + it('returns empty when no data', () => { + expect(getSnoozeHistory()).toEqual([]); + }); + + it('clears history', () => { + saveSnoozeHistory(makeRecords('Alarm', 5)); + clearSnoozeHistory(); + expect(getSnoozeHistory()).toEqual([]); + }); + + it('trims to 200 records', () => { + const records = makeRecords('Alarm', 250); + saveSnoozeHistory(records); + expect(getSnoozeHistory()).toHaveLength(200); + }); +}); + +describe('recordSnooze', () => { + it('records a snoozed timer', () => { + const timer = makeTimer({ snoozeCount: 2 }); + const record = recordSnooze(timer); + expect(record).not.toBeNull(); + expect(record!.snoozeCount).toBe(2); + expect(record!.label).toBe('Morning Alarm'); + expect(getSnoozeHistory()).toHaveLength(1); + }); + + it('returns null for zero-snooze timers', () => { + const timer = makeTimer({ snoozeCount: 0 }); + expect(recordSnooze(timer)).toBeNull(); + }); + + it('estimates minutes from snoozedUntil when available', () => { + const firedAt = Date.now() - 20 * 60_000; + const snoozedUntil = Date.now(); + const timer = makeTimer({ firedAt, snoozedUntil, snoozeCount: 2 }); + const record = recordSnooze(timer); + expect(record!.totalSnoozeMinutes).toBe(20); + }); + + it('falls back to 5m per snooze', () => { + const timer = makeTimer({ firedAt: null, snoozedUntil: null, snoozeCount: 4 }); + const record = recordSnooze(timer); + expect(record!.totalSnoozeMinutes).toBe(20); + }); +}); + +describe('getSnoozeStats', () => { + it('groups records by normalized label', () => { + const records = [ + ...makeRecords('Morning Alarm', 3), + ...makeRecords('morning alarm', 2), + ...makeRecords('Meeting with Bob', 4), + ...makeRecords('Meeting with Alice', 3), + ]; + const stats = getSnoozeStats(records); + expect(stats.get('morning alarm')).toHaveLength(5); + expect(stats.get('meeting with')).toHaveLength(7); + }); +}); + +describe('getSnoozeSuggestions', () => { + it('returns suggestions when enough data points', () => { + const records = makeRecords('Morning Alarm', 6, 3); + const suggestions = getSnoozeSuggestions(records); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].avgSnoozeCount).toBeCloseTo(3); + expect(suggestions[0].suggestedDelayMinutes).toBe(15); + expect(suggestions[0].message).toContain('morning alarm'); + }); + + it('returns empty when not enough data points', () => { + const records = makeRecords('Alarm', 3, 3); + expect(getSnoozeSuggestions(records)).toEqual([]); + }); + + it('skips low-snooze patterns', () => { + const records = makeRecords('Quick timer', 6, 1); // avg 1.0 < 1.5 + expect(getSnoozeSuggestions(records)).toEqual([]); + }); + + it('sorts by most snoozed first', () => { + const records = [ + ...makeRecords('Alarm A', 5, 2), + ...makeRecords('Alarm B', 5, 5), + ]; + const suggestions = getSnoozeSuggestions(records); + expect(suggestions[0].labelPattern).toBe('alarm b'); + }); + + it('uses custom min data points', () => { + const records = makeRecords('Alarm', 3, 3); + const suggestions = getSnoozeSuggestions(records, 2); + expect(suggestions).toHaveLength(1); + }); +}); + +describe('checkForSnoozeSuggestion', () => { + it('finds matching suggestion by label', () => { + saveSnoozeHistory(makeRecords('Morning Alarm', 6, 3)); + const suggestion = checkForSnoozeSuggestion('Morning Alarm'); + expect(suggestion).not.toBeNull(); + expect(suggestion!.suggestedDelayMinutes).toBe(15); + }); + + it('finds matching suggestion by category', () => { + const records = makeRecords('Random work task', 6, 4); + records.forEach((r) => (r.category = 'work')); + saveSnoozeHistory(records); + const suggestion = checkForSnoozeSuggestion('Different label', 'work'); + expect(suggestion).not.toBeNull(); + }); + + it('returns null when no match', () => { + saveSnoozeHistory(makeRecords('Alarm', 6, 3)); + expect(checkForSnoozeSuggestion('Completely different')).toBeNull(); + }); +}); diff --git a/web/src/lib/adaptive-snooze.ts b/web/src/lib/adaptive-snooze.ts new file mode 100644 index 0000000..21681ac --- /dev/null +++ b/web/src/lib/adaptive-snooze.ts @@ -0,0 +1,187 @@ +// ── Adaptive Snooze Learning ────────────────────────────────── +// Track snooze patterns and suggest timer adjustments + +import type { Timer } from './timer-engine'; + +// ── Types ───────────────────────────────────────────────────── + +export interface SnoozeRecord { + timerId: string; + label: string; + category?: string; + snoozeCount: number; + totalSnoozeMinutes: number; + timestamp: number; +} + +export interface SnoozeSuggestion { + category?: string; + labelPattern: string; + avgSnoozeCount: number; + avgSnoozeMinutes: number; + suggestedDelayMinutes: number; + message: string; + dataPoints: number; +} + +const STORAGE_KEY = 'chronomind-snooze-history'; +const MIN_DATA_POINTS = 5; + +// ── Persistence ─────────────────────────────────────────────── + +export function getSnoozeHistory(): SnoozeRecord[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +export function saveSnoozeHistory(records: SnoozeRecord[]): void { + if (typeof window === 'undefined') return; + // Keep last 200 records max + const trimmed = records.slice(-200); + localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed)); +} + +export function clearSnoozeHistory(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(STORAGE_KEY); +} + +// ── Recording ───────────────────────────────────────────────── + +/** + * Record a timer's snooze behavior when it's dismissed or completed. + * Call this when a timer reaches a terminal state. + */ +export function recordSnooze(timer: Timer): SnoozeRecord | null { + if (timer.snoozeCount === 0) return null; + + const record: SnoozeRecord = { + timerId: timer.id, + label: timer.label, + category: timer.category, + snoozeCount: timer.snoozeCount, + totalSnoozeMinutes: estimateSnoozeMinutes(timer), + timestamp: Date.now(), + }; + + const history = getSnoozeHistory(); + history.push(record); + saveSnoozeHistory(history); + return record; +} + +/** + * Estimate total snooze time based on snooze count. + * Default assumption: 5 minutes per snooze (most common). + */ +function estimateSnoozeMinutes(timer: Timer): number { + // If snoozedUntil is set, calculate from firedAt + if (timer.firedAt && timer.snoozedUntil) { + return Math.round((timer.snoozedUntil - timer.firedAt) / 60_000); + } + // Fallback: assume 5 min per snooze + return timer.snoozeCount * 5; +} + +// ── Analysis ────────────────────────────────────────────────── + +/** + * Normalize a timer label to a pattern for grouping. + * "Morning Alarm" and "morning alarm" → "morning alarm" + * "Meeting with Bob" and "Meeting with Alice" → "meeting with" + */ +export function normalizeLabel(label: string): string { + return label + .toLowerCase() + .trim() + .replace(/\s+/g, ' ') + // Remove trailing proper nouns / specifics after common prefixes + .replace(/(meeting|call|sync|standup|review|session)\s+with\s+\w+.*$/, '$1 with') + .replace(/(alarm|timer|reminder)\s*#?\d+.*$/, '$1') + .replace(/\s*\d{1,2}:\d{2}\s*(am|pm)?/i, ''); // remove times +} + +/** + * Get snooze statistics grouped by label pattern and/or category. + */ +export function getSnoozeStats(history: SnoozeRecord[]): Map { + const groups = new Map(); + + for (const record of history) { + // Group by normalized label + const key = normalizeLabel(record.label); + const existing = groups.get(key) ?? []; + existing.push(record); + groups.set(key, existing); + } + + return groups; +} + +/** + * Generate snooze suggestions based on history patterns. + * Only suggests when there are enough data points (≥5 by default). + */ +export function getSnoozeSuggestions( + history?: SnoozeRecord[], + minDataPoints: number = MIN_DATA_POINTS +): SnoozeSuggestion[] { + const records = history ?? getSnoozeHistory(); + const stats = getSnoozeStats(records); + const suggestions: SnoozeSuggestion[] = []; + + for (const [pattern, group] of stats) { + if (group.length < minDataPoints) continue; + + const avgSnoozeCount = group.reduce((s, r) => s + r.snoozeCount, 0) / group.length; + const avgSnoozeMinutes = group.reduce((s, r) => s + r.totalSnoozeMinutes, 0) / group.length; + + // Only suggest if average snooze count is meaningful (> 1.5) + if (avgSnoozeCount < 1.5) continue; + + const suggestedDelay = Math.round(avgSnoozeMinutes); + const category = group[0].category; + + suggestions.push({ + category, + labelPattern: pattern, + avgSnoozeCount: Math.round(avgSnoozeCount * 10) / 10, + avgSnoozeMinutes: Math.round(avgSnoozeMinutes), + suggestedDelayMinutes: suggestedDelay, + dataPoints: group.length, + message: `You snooze "${pattern}" timers ~${Math.round(avgSnoozeCount)}x (avg ${suggestedDelay}m). Set them ${suggestedDelay} minutes later?`, + }); + } + + // Sort by most-snoozed first + return suggestions.sort((a, b) => b.avgSnoozeCount - a.avgSnoozeCount); +} + +/** + * Check if a newly created timer matches a known snooze pattern, + * and return a suggestion if so. + */ +export function checkForSnoozeSuggestion( + label: string, + category?: string +): SnoozeSuggestion | null { + const suggestions = getSnoozeSuggestions(); + const normalized = normalizeLabel(label); + + // Exact label pattern match + const match = suggestions.find((s) => s.labelPattern === normalized); + if (match) return match; + + // Category match (if label doesn't match but category does) + if (category) { + const catMatch = suggestions.find((s) => s.category === category); + if (catMatch) return catMatch; + } + + return null; +} diff --git a/web/src/lib/analytics.ts b/web/src/lib/analytics.ts index 8bf8359..f36d318 100644 --- a/web/src/lib/analytics.ts +++ b/web/src/lib/analytics.ts @@ -3,7 +3,7 @@ // platform-service telemetry later. type AnalyticsEvent = - | { name: 'timer_created'; props: { type: 'alarm' | 'countdown' | 'pomodoro'; urgency: string; cascade: string } } + | { name: 'timer_created'; props: { type: 'alarm' | 'countdown' | 'pomodoro' | 'event'; urgency: string; cascade: string } } | { name: 'timer_completed'; props: { type: string; durationMs: number } } | { name: 'cascade_fired'; props: { timerId: string; minutesBefore: number } } | { name: 'pomodoro_completed'; props: { rounds: number; totalMinutes: number } } @@ -19,7 +19,7 @@ function track(event: AnalyticsEvent): void { // Future: send to Plausible, platform-service telemetry, etc. } -export function trackTimerCreated(type: 'alarm' | 'countdown' | 'pomodoro', urgency: string, cascade: string): void { +export function trackTimerCreated(type: 'alarm' | 'countdown' | 'pomodoro' | 'event', urgency: string, cascade: string): void { track({ name: 'timer_created', props: { type, urgency, cascade } }); } diff --git a/web/src/lib/prep-time.test.ts b/web/src/lib/prep-time.test.ts new file mode 100644 index 0000000..6682397 --- /dev/null +++ b/web/src/lib/prep-time.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { + getSuggestedPrepTime, + getTotalPrepMinutes, + getPrepStartTime, + formatPrepWarning, + shouldShowPrepWarning, + getPrepKeywords, +} from './prep-time'; + +describe('getSuggestedPrepTime', () => { + it('returns keyword-based config for "meeting"', () => { + const config = getSuggestedPrepTime('Team Meeting'); + expect(config.prepMinutes).toBe(10); + expect(config.message).toContain('agenda'); + }); + + it('returns keyword-based config for "flight"', () => { + const config = getSuggestedPrepTime('Flight to NYC'); + expect(config.prepMinutes).toBe(60); + expect(config.travelMinutes).toBe(60); + }); + + it('returns keyword-based config for "dentist"', () => { + const config = getSuggestedPrepTime('Dentist appointment'); + expect(config.prepMinutes).toBe(15); + expect(config.travelMinutes).toBe(20); + }); + + it('returns keyword-based config for "interview"', () => { + const config = getSuggestedPrepTime('Job Interview'); + expect(config.prepMinutes).toBe(30); + }); + + it('returns keyword-based config for "gym"', () => { + const config = getSuggestedPrepTime('Go to gym'); + expect(config.prepMinutes).toBe(10); + expect(config.travelMinutes).toBe(10); + }); + + it('returns keyword-based config for "exam"', () => { + const config = getSuggestedPrepTime('Math Exam'); + expect(config.prepMinutes).toBe(30); + }); + + it('falls back to category default when no keyword match', () => { + const config = getSuggestedPrepTime('Random task', 'cooking'); + expect(config.prepMinutes).toBe(15); + expect(config.message).toContain('ingredients'); + }); + + it('returns generic default when no keyword or category match', () => { + const config = getSuggestedPrepTime('Something'); + expect(config.prepMinutes).toBe(5); + expect(config.travelMinutes).toBe(0); + }); + + it('keyword takes priority over category', () => { + const config = getSuggestedPrepTime('Flight to LA', 'personal'); + expect(config.prepMinutes).toBe(60); // keyword "flight", not category "personal" (5) + }); + + it('handles case-insensitive matching', () => { + const config = getSuggestedPrepTime('PRESENTATION at work'); + expect(config.prepMinutes).toBe(20); + }); +}); + +describe('getTotalPrepMinutes', () => { + it('sums prep and travel', () => { + expect(getTotalPrepMinutes({ prepMinutes: 10, travelMinutes: 20, message: '' })).toBe(30); + }); + + it('handles zero travel', () => { + expect(getTotalPrepMinutes({ prepMinutes: 15, travelMinutes: 0, message: '' })).toBe(15); + }); +}); + +describe('getPrepStartTime', () => { + it('calculates correct start time', () => { + const target = 1000 * 60 * 60; // 1 hour in ms + const config = { prepMinutes: 10, travelMinutes: 5, message: '' }; + const start = getPrepStartTime(target, config); + expect(start).toBe(target - 15 * 60_000); + }); +}); + +describe('formatPrepWarning', () => { + const config = { prepMinutes: 10, travelMinutes: 5, message: 'Review your agenda' }; + + it('returns "Time\'s up" when no time left', () => { + expect(formatPrepWarning(config, 0)).toContain("Time's up"); + }); + + it('returns "Start now" when within total prep window', () => { + expect(formatPrepWarning(config, 10)).toContain('Start now'); + }); + + it('returns "Prepare soon" when close to prep start', () => { + expect(formatPrepWarning(config, 18)).toContain('Prepare soon'); + }); + + it('returns prep start countdown when far away', () => { + expect(formatPrepWarning(config, 60)).toContain('prep starts in 45m'); + }); +}); + +describe('shouldShowPrepWarning', () => { + const config = { prepMinutes: 10, travelMinutes: 5, message: '' }; + const target = Date.now() + 60 * 60_000; // 1 hour from now + + it('returns false when too early', () => { + const earlyNow = target - 90 * 60_000; // 90 min before + expect(shouldShowPrepWarning(target, config, earlyNow)).toBe(false); + }); + + it('returns true within prep window', () => { + const inPrepWindow = target - 10 * 60_000; // 10 min before (within 15m window) + expect(shouldShowPrepWarning(target, config, inPrepWindow)).toBe(true); + }); + + it('returns false after target time', () => { + const afterTarget = target + 60_000; + expect(shouldShowPrepWarning(target, config, afterTarget)).toBe(false); + }); + + it('returns true exactly at prep start', () => { + const prepStart = target - 15 * 60_000; + expect(shouldShowPrepWarning(target, config, prepStart)).toBe(true); + }); +}); + +describe('getPrepKeywords', () => { + it('returns an array of keyword strings', () => { + const keywords = getPrepKeywords(); + expect(keywords.length).toBeGreaterThan(10); + expect(keywords).toContain('meeting'); + expect(keywords).toContain('flight'); + expect(keywords).toContain('dentist'); + }); +}); diff --git a/web/src/lib/prep-time.ts b/web/src/lib/prep-time.ts new file mode 100644 index 0000000..f05ef9a --- /dev/null +++ b/web/src/lib/prep-time.ts @@ -0,0 +1,149 @@ +// ── Prep Time Intelligence ──────────────────────────────────── +// Auto-suggest prep time based on timer category/label, generate prep warnings + +export interface PrepTimeConfig { + prepMinutes: number; + travelMinutes: number; + message: string; +} + +// ── Category-based Defaults ────────────────────────────────── + +const CATEGORY_PREP_DEFAULTS: Record = { + work: { prepMinutes: 5, travelMinutes: 0, message: 'Review your agenda and materials' }, + personal: { prepMinutes: 5, travelMinutes: 0, message: 'Get ready' }, + health: { prepMinutes: 10, travelMinutes: 15, message: 'Pack your gym bag and water bottle' }, + cooking: { prepMinutes: 15, travelMinutes: 0, message: 'Gather ingredients and equipment' }, + exercise: { prepMinutes: 10, travelMinutes: 0, message: 'Change into workout clothes and warm up' }, + study: { prepMinutes: 5, travelMinutes: 0, message: 'Set up your study space and materials' }, +}; + +// ── Keyword-based Overrides ────────────────────────────────── + +interface KeywordPrepRule { + keywords: string[]; + config: PrepTimeConfig; +} + +const KEYWORD_PREP_RULES: KeywordPrepRule[] = [ + { + keywords: ['meeting', 'standup', 'sync', 'call', 'conference'], + config: { prepMinutes: 10, travelMinutes: 0, message: 'Review your agenda and join link' }, + }, + { + keywords: ['flight', 'airport', 'plane'], + config: { prepMinutes: 60, travelMinutes: 60, message: 'Check in online, pack bags, head to airport' }, + }, + { + keywords: ['dentist', 'doctor', 'appointment', 'clinic', 'hospital'], + config: { prepMinutes: 15, travelMinutes: 20, message: 'Bring insurance card and ID, leave early' }, + }, + { + keywords: ['interview'], + config: { prepMinutes: 30, travelMinutes: 15, message: 'Review your resume, dress up, and test your setup' }, + }, + { + keywords: ['presentation', 'demo', 'pitch'], + config: { prepMinutes: 20, travelMinutes: 0, message: 'Test slides, check AV setup, rehearse key points' }, + }, + { + keywords: ['gym', 'workout', 'run', 'jog', 'yoga', 'swim'], + config: { prepMinutes: 10, travelMinutes: 10, message: 'Change clothes, pack gym bag, fill water bottle' }, + }, + { + keywords: ['dinner', 'lunch', 'restaurant', 'reservation'], + config: { prepMinutes: 15, travelMinutes: 20, message: 'Get dressed and leave on time for your reservation' }, + }, + { + keywords: ['exam', 'test', 'quiz'], + config: { prepMinutes: 30, travelMinutes: 15, message: 'Final review, pack supplies (pens, calculator, ID)' }, + }, + { + keywords: ['school', 'class', 'lecture'], + config: { prepMinutes: 10, travelMinutes: 15, message: 'Pack your bag and review last session notes' }, + }, + { + keywords: ['date', 'party', 'event', 'concert', 'show'], + config: { prepMinutes: 30, travelMinutes: 20, message: 'Get ready, check tickets/directions' }, + }, + { + keywords: ['pickup', 'drop off', 'dropoff'], + config: { prepMinutes: 5, travelMinutes: 15, message: 'Get keys and head out' }, + }, + { + keywords: ['cook', 'bake', 'recipe'], + config: { prepMinutes: 15, travelMinutes: 0, message: 'Preheat oven, gather ingredients and utensils' }, + }, +]; + +/** + * Get suggested prep time based on label and optional category. + * Keyword rules take priority over category defaults. + */ +export function getSuggestedPrepTime(label: string, category?: string): PrepTimeConfig { + const lower = label.toLowerCase(); + + // Check keyword rules first (most specific) + for (const rule of KEYWORD_PREP_RULES) { + if (rule.keywords.some((kw) => lower.includes(kw))) { + return rule.config; + } + } + + // Fall back to category default + if (category) { + const catDefault = CATEGORY_PREP_DEFAULTS[category.toLowerCase()]; + if (catDefault) return catDefault; + } + + // Generic default + return { prepMinutes: 5, travelMinutes: 0, message: 'Start preparing' }; +} + +/** + * Calculate total prep time in minutes (prep + travel). + */ +export function getTotalPrepMinutes(config: PrepTimeConfig): number { + return config.prepMinutes + config.travelMinutes; +} + +/** + * Calculate the time at which the user should start preparing, + * given the timer's target time and prep config. + */ +export function getPrepStartTime(targetTime: number, config: PrepTimeConfig): number { + return targetTime - getTotalPrepMinutes(config) * 60_000; +} + +/** + * Generate a prep warning message with context. + */ +export function formatPrepWarning(config: PrepTimeConfig, minutesUntilTarget: number): string { + const totalPrep = getTotalPrepMinutes(config); + if (minutesUntilTarget <= 0) { + return `Time's up! ${config.message}`; + } + if (minutesUntilTarget <= totalPrep) { + return `Start now — ${config.message}`; + } + const minutesUntilPrep = minutesUntilTarget - totalPrep; + if (minutesUntilPrep <= 5) { + return `Prepare soon (${minutesUntilPrep}m) — ${config.message}`; + } + return `${config.message} (prep starts in ${minutesUntilPrep}m)`; +} + +/** + * Check if a timer should show a prep warning at the current time. + */ +export function shouldShowPrepWarning(targetTime: number, config: PrepTimeConfig, now: number): boolean { + const prepStart = getPrepStartTime(targetTime, config); + return now >= prepStart && now < targetTime; +} + +/** + * Get all keyword categories for display/autocomplete. + */ +export function getPrepKeywords(): string[] { + return KEYWORD_PREP_RULES.flatMap((r) => r.keywords); +} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts index 8110b1a..1a259b0 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/store.ts @@ -1,11 +1,12 @@ // ── Zustand Store with IndexedDB Persistence ─────────────────── import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; -import type { Timer, CreateAlarmParams, CreateCountdownParams, CreatePomodoroParams } from './timer-engine'; +import type { Timer, CreateAlarmParams, CreateCountdownParams, CreatePomodoroParams, CreateEventParams } from './timer-engine'; import { createAlarm, createCountdown, createPomodoro, + createEvent, pauseTimer, resumeTimer, fireTimer, @@ -28,6 +29,7 @@ export interface TimerStore { addAlarm: (params: CreateAlarmParams) => Timer; addCountdown: (params: CreateCountdownParams) => Timer; addPomodoro: (params?: CreatePomodoroParams) => Timer; + addEvent: (params: CreateEventParams) => Timer; removeTimer: (id: string) => void; // State transitions @@ -79,6 +81,13 @@ export const useTimerStore = create()( return timer; }, + addEvent: (params) => { + const timer = createEvent(params); + set((s) => ({ timers: [...s.timers, timer] })); + trackTimerCreated('event', timer.urgency, timer.cascade?.preset ?? 'none'); + return timer; + }, + removeTimer: (id) => { set((s) => ({ timers: s.timers.filter((t) => t.id !== id) })); }, diff --git a/web/src/lib/timer-engine.ts b/web/src/lib/timer-engine.ts index 8460deb..b902d11 100644 --- a/web/src/lib/timer-engine.ts +++ b/web/src/lib/timer-engine.ts @@ -159,6 +159,52 @@ export function createCountdown(params: CreateCountdownParams): Timer { }; } +export interface CreateEventParams { + label: string; + targetTime: number; // epoch ms — the future event date + urgency?: UrgencyLevel; + cascade?: CascadeConfig; + category?: string; + description?: string; + milestones?: number[]; // days before event to warn (e.g. [30, 7, 1]) +} + +export const DEFAULT_EVENT_MILESTONES = [30, 7, 3, 1]; // days before + +export function createEvent(params: CreateEventParams): Timer { + const now = Date.now(); + const milestones = params.milestones ?? DEFAULT_EVENT_MILESTONES; + // Convert milestone days to cascade warning minutes + const milestoneMinutes = milestones + .map((days) => days * 24 * 60) + .filter((mins) => mins * 60_000 < (params.targetTime - now)); // only include if before now + + const cascade = params.cascade ?? { preset: 'custom', intervals: [] }; + + return { + id: uuidv4(), + type: 'event', + label: params.label, + description: params.description, + urgency: params.urgency ?? 'gentle', + state: 'active', + targetTime: params.targetTime, + duration: params.targetTime - now, + createdAt: now, + startedAt: now, + pausedAt: null, + firedAt: null, + dismissedAt: null, + completedAt: null, + elapsedBeforePause: 0, + cascade, + warnings: calculateCascadeWarnings(params.targetTime, milestoneMinutes, now), + snoozeCount: 0, + snoozedUntil: null, + category: params.category, + }; +} + export interface CreatePomodoroParams { label?: string; config?: Partial;