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:
parent
9fcd256364
commit
48a4b7d024
@ -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 · Milestone warnings at 30, 7, 3, 1 days
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
{tab !== 'pomodoro' && (
|
{tab !== 'pomodoro' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -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)' }}>
|
||||||
|
|||||||
209
web/src/lib/adaptive-snooze.test.ts
Normal file
209
web/src/lib/adaptive-snooze.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
187
web/src/lib/adaptive-snooze.ts
Normal file
187
web/src/lib/adaptive-snooze.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
web/src/lib/prep-time.test.ts
Normal file
141
web/src/lib/prep-time.test.ts
Normal 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
149
web/src/lib/prep-time.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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) }));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user