- 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
188 lines
6.0 KiB
TypeScript
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;
|
|
}
|