learning_ai_clock/web/src/lib/adaptive-snooze.ts
saravanakumardb1 48a4b7d024 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
2026-02-27 22:25:36 -08:00

188 lines
6.0 KiB
TypeScript

// ── 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;
}