feat(web): prep-time intelligence, adaptive snooze, event countdown, TimerCard badges

- Prep time intelligence (lib/prep-time.ts): 12 keyword rules + 6 category defaults, prep/travel time suggestions, warning formatter, shouldShowPrepWarning() (22 tests)
- Adaptive snooze learning (lib/adaptive-snooze.ts): snooze pattern tracking, label normalization, suggestion engine with 5+ data point threshold, localStorage persistence (22 tests)
- Event countdown timer: createEvent factory with milestone warnings (30/7/3/1 days), addEvent store action, Event tab in CreateTimerModal with date picker
- TimerCard: category badge, chain link badge, prep time warning integration
- Analytics: added 'event' to trackTimerCreated type union
- Updated roadmap: marked prep time, adaptive snooze, event countdown, export/import, history as completed
- Phase 2 exit criteria: 6/10 met, 373 tests across 16 files, tsc clean
This commit is contained in:
saravanakumardb1 2026-02-27 22:25:36 -08:00
parent 9fcd256364
commit 48a4b7d024
9 changed files with 822 additions and 6 deletions

View File

@ -6,12 +6,12 @@ import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
import type { UrgencyLevel } from '@/lib/urgency'; import type { UrgencyLevel } from '@/lib/urgency';
import { CASCADE_PRESET_LABELS } from '@/lib/cascade'; import { CASCADE_PRESET_LABELS } from '@/lib/cascade';
import type { CascadePreset } 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 { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
import { parseNaturalLanguage } from '@/lib/nl-parser'; import { parseNaturalLanguage } from '@/lib/nl-parser';
import type { ParseResult } from '@/lib/nl-parser'; import type { ParseResult } from '@/lib/nl-parser';
type TabType = 'alarm' | 'countdown' | 'pomodoro'; type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
interface CreateTimerModalProps { interface CreateTimerModalProps {
isOpen: boolean; isOpen: boolean;
@ -19,7 +19,7 @@ interface CreateTimerModalProps {
} }
export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const { addAlarm, addCountdown, addPomodoro } = useTimerStore(); const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore();
const [tab, setTab] = useState<TabType>('countdown'); const [tab, setTab] = useState<TabType>('countdown');
const [nlInput, setNlInput] = useState(''); const [nlInput, setNlInput] = useState('');
@ -43,6 +43,9 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const [longBreakMin, setLongBreakMin] = useState(15); const [longBreakMin, setLongBreakMin] = useState(15);
const [rounds, setRounds] = useState(4); const [rounds, setRounds] = useState(4);
// Event fields
const [eventDate, setEventDate] = useState('');
if (!isOpen) return null; if (!isOpen) return null;
const handleNlChange = (value: string) => { const handleNlChange = (value: string) => {
@ -136,6 +139,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
}, },
urgency, 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 // Reset form
@ -145,6 +158,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
setMinutes(25); setMinutes(25);
setSeconds(0); setSeconds(0);
setCategory(''); setCategory('');
setEventDate('');
onClose(); onClose();
}; };
@ -152,6 +166,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
{ key: 'countdown', label: 'Countdown', icon: <Timer size={16} /> }, { key: 'countdown', label: 'Countdown', icon: <Timer size={16} /> },
{ key: 'alarm', label: 'Alarm', icon: <AlarmClock size={16} /> }, { key: 'alarm', label: 'Alarm', icon: <AlarmClock size={16} /> },
{ key: 'pomodoro', label: 'Pomodoro', icon: <Coffee size={16} /> }, { key: 'pomodoro', label: 'Pomodoro', icon: <Coffee size={16} /> },
{ key: 'event', label: 'Event', icon: <CalendarDays size={16} /> },
]; ];
return ( return (
@ -373,6 +388,31 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
</div> </div>
)} )}
{tab === 'event' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Event Date
</label>
<input
type="date"
value={eventDate}
onChange={(e) => 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() && (
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now &middot; Milestone warnings at 30, 7, 3, 1 days
</p>
)}
</div>
)}
{/* Category */} {/* Category */}
{tab !== 'pomodoro' && ( {tab !== 'pomodoro' && (
<div> <div>

View File

@ -16,8 +16,12 @@ import {
Coffee, Coffee,
Bell, Bell,
BellOff, BellOff,
Repeat,
Link2,
Tag,
} from 'lucide-react'; } from 'lucide-react';
import { getTimeReferenceMs } from '@/lib/time-blindness'; import { getTimeReferenceMs } from '@/lib/time-blindness';
import { getSuggestedPrepTime, shouldShowPrepWarning, formatPrepWarning } from '@/lib/prep-time';
interface TimerCardProps { interface TimerCardProps {
timer: Timer; timer: Timer;
@ -91,6 +95,23 @@ export function TimerCard({ timer }: TimerCardProps) {
> >
{urgencyConfig.label} {urgencyConfig.label}
</span> </span>
{timer.category && (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium flex items-center gap-1"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-tertiary)' }}
>
<Tag size={10} /> {timer.category}
</span>
)}
{timer.linkedTimerId && (
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{ backgroundColor: 'rgba(90,140,255,0.15)', color: 'var(--cm-accent)' }}
title="Part of a chain"
>
<Link2 size={12} />
</span>
)}
</div> </div>
<span <span
className="text-xs font-mono px-2 py-0.5 rounded" className="text-xs font-mono px-2 py-0.5 rounded"
@ -156,6 +177,20 @@ export function TimerCard({ timer }: TimerCardProps) {
</p> </p>
)} )}
{/* 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 (
<p className="text-xs mb-3 flex items-center gap-1 font-medium" style={{ color: 'var(--cm-warning)' }}>
{formatPrepWarning(prepConfig, minutesUntil)}
</p>
);
}
return null;
})()}
{/* Snooze info */} {/* Snooze info */}
{timer.snoozeCount > 0 && ( {timer.snoozeCount > 0 && (
<p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}>

View File

@ -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<string, string> = {};
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> = {}): 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();
});
});

View File

@ -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<string, SnoozeRecord[]> {
const groups = new Map<string, SnoozeRecord[]>();
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;
}

View File

@ -3,7 +3,7 @@
// platform-service telemetry later. // platform-service telemetry later.
type AnalyticsEvent = 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: 'timer_completed'; props: { type: string; durationMs: number } }
| { name: 'cascade_fired'; props: { timerId: string; minutesBefore: number } } | { name: 'cascade_fired'; props: { timerId: string; minutesBefore: number } }
| { name: 'pomodoro_completed'; props: { rounds: number; totalMinutes: 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. // 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 } }); track({ name: 'timer_created', props: { type, urgency, cascade } });
} }

View File

@ -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');
});
});

149
web/src/lib/prep-time.ts Normal file
View File

@ -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<string, PrepTimeConfig> = {
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);
}

View File

@ -1,11 +1,12 @@
// ── Zustand Store with IndexedDB Persistence ─────────────────── // ── Zustand Store with IndexedDB Persistence ───────────────────
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; 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 { import {
createAlarm, createAlarm,
createCountdown, createCountdown,
createPomodoro, createPomodoro,
createEvent,
pauseTimer, pauseTimer,
resumeTimer, resumeTimer,
fireTimer, fireTimer,
@ -28,6 +29,7 @@ export interface TimerStore {
addAlarm: (params: CreateAlarmParams) => Timer; addAlarm: (params: CreateAlarmParams) => Timer;
addCountdown: (params: CreateCountdownParams) => Timer; addCountdown: (params: CreateCountdownParams) => Timer;
addPomodoro: (params?: CreatePomodoroParams) => Timer; addPomodoro: (params?: CreatePomodoroParams) => Timer;
addEvent: (params: CreateEventParams) => Timer;
removeTimer: (id: string) => void; removeTimer: (id: string) => void;
// State transitions // State transitions
@ -79,6 +81,13 @@ export const useTimerStore = create<TimerStore>()(
return timer; 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) => { removeTimer: (id) => {
set((s) => ({ timers: s.timers.filter((t) => t.id !== id) })); set((s) => ({ timers: s.timers.filter((t) => t.id !== id) }));
}, },

View File

@ -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 { export interface CreatePomodoroParams {
label?: string; label?: string;
config?: Partial<PomodoroConfig>; config?: Partial<PomodoroConfig>;