learning_ai_clock/web/src/lib/timer-engine.ts

425 lines
12 KiB
TypeScript

// ── Timer Engine ───────────────────────────────────────────────
// Core timer types, state machine, and lifecycle management
import { v4 as uuidv4 } from 'uuid';
import type { UrgencyLevel } from './urgency';
import type { CascadeConfig, CascadeWarning } from './cascade';
import { calculateCascadeWarnings, getCascadeIntervals } from './cascade';
// ── Types ──────────────────────────────────────────────────────
export type TimerType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
export type TimerState =
| 'idle'
| 'active'
| 'warning'
| 'firing'
| 'snoozed'
| 'dismissed'
| 'completed'
| 'paused';
export interface Timer {
id: string;
type: TimerType;
label: string;
description?: string;
urgency: UrgencyLevel;
state: TimerState;
// Time fields
targetTime: number; // epoch ms — when the timer fires
duration: number | null; // ms — for countdowns/pomodoro
createdAt: number;
startedAt: number | null;
pausedAt: number | null;
firedAt: number | null;
dismissedAt: number | null;
completedAt: number | null;
// Elapsed tracking for pause/resume
elapsedBeforePause: number; // ms accumulated before last pause
// Cascade
cascade: CascadeConfig;
warnings: CascadeWarning[];
// Pomodoro-specific
pomodoroConfig?: PomodoroConfig;
pomodoroState?: PomodoroState;
// Snooze
snoozeCount: number;
snoozedUntil: number | null;
// Metadata
category?: string;
tags?: string[];
linkedTimerId?: string | null;
recurringTimerId?: string | null;
}
export interface PomodoroConfig {
workMinutes: number;
breakMinutes: number;
longBreakMinutes: number;
rounds: number;
}
export interface PomodoroState {
currentRound: number;
isBreak: boolean;
isLongBreak: boolean;
completedRounds: number;
}
export const DEFAULT_POMODORO_CONFIG: PomodoroConfig = {
workMinutes: 25,
breakMinutes: 5,
longBreakMinutes: 15,
rounds: 4,
};
// ── Factory Functions ──────────────────────────────────────────
export interface CreateAlarmParams {
label: string;
targetTime: number;
urgency?: UrgencyLevel;
cascade?: CascadeConfig;
category?: string;
description?: string;
}
export function createAlarm(params: CreateAlarmParams): Timer {
const cascade = params.cascade ?? { preset: 'standard', intervals: [] };
const intervals = getCascadeIntervals(cascade);
const now = Date.now();
return {
id: uuidv4(),
type: 'alarm',
label: params.label,
description: params.description,
urgency: params.urgency ?? 'standard',
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, intervals, now),
snoozeCount: 0,
snoozedUntil: null,
category: params.category,
};
}
export interface CreateCountdownParams {
label: string;
durationMs: number;
urgency?: UrgencyLevel;
cascade?: CascadeConfig;
category?: string;
description?: string;
}
export function createCountdown(params: CreateCountdownParams): Timer {
const now = Date.now();
const targetTime = now + params.durationMs;
const cascade = params.cascade ?? { preset: 'standard', intervals: [] };
const intervals = getCascadeIntervals(cascade);
return {
id: uuidv4(),
type: 'countdown',
label: params.label,
description: params.description,
urgency: params.urgency ?? 'standard',
state: 'active',
targetTime,
duration: params.durationMs,
createdAt: now,
startedAt: now,
pausedAt: null,
firedAt: null,
dismissedAt: null,
completedAt: null,
elapsedBeforePause: 0,
cascade,
warnings: calculateCascadeWarnings(targetTime, intervals, now),
snoozeCount: 0,
snoozedUntil: null,
category: params.category,
};
}
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<PomodoroConfig>;
urgency?: UrgencyLevel;
}
export function createPomodoro(params: CreatePomodoroParams = {}): Timer {
const now = Date.now();
const config = { ...DEFAULT_POMODORO_CONFIG, ...params.config };
const durationMs = config.workMinutes * 60 * 1000;
const targetTime = now + durationMs;
return {
id: uuidv4(),
type: 'pomodoro',
label: params.label ?? 'Focus Session',
urgency: params.urgency ?? 'standard',
state: 'active',
targetTime,
duration: durationMs,
createdAt: now,
startedAt: now,
pausedAt: null,
firedAt: null,
dismissedAt: null,
completedAt: null,
elapsedBeforePause: 0,
cascade: { preset: 'minimal', intervals: [] },
warnings: calculateCascadeWarnings(targetTime, [1], now),
pomodoroConfig: config,
pomodoroState: {
currentRound: 1,
isBreak: false,
isLongBreak: false,
completedRounds: 0,
},
snoozeCount: 0,
snoozedUntil: null,
};
}
// ── State Transitions ──────────────────────────────────────────
export function pauseTimer(timer: Timer): Timer {
if (timer.state !== 'active' && timer.state !== 'warning') return timer;
const now = Date.now();
const elapsed = timer.elapsedBeforePause + (now - (timer.startedAt ?? now));
return {
...timer,
state: 'paused',
pausedAt: now,
elapsedBeforePause: elapsed,
};
}
export function resumeTimer(timer: Timer): Timer {
if (timer.state !== 'paused') return timer;
const now = Date.now();
const remainingMs = (timer.duration ?? 0) - timer.elapsedBeforePause;
const newTargetTime = now + remainingMs;
const intervals = getCascadeIntervals(timer.cascade);
return {
...timer,
state: 'active',
startedAt: now,
pausedAt: null,
targetTime: newTargetTime,
warnings: calculateCascadeWarnings(newTargetTime, intervals, now),
};
}
export function fireTimer(timer: Timer): Timer {
if (timer.state === 'dismissed' || timer.state === 'completed') return timer;
return {
...timer,
state: 'firing',
firedAt: Date.now(),
};
}
export function snoozeTimer(timer: Timer, snoozeMinutes: number): Timer {
if (timer.state !== 'firing') return timer;
const now = Date.now();
const snoozeUntil = now + snoozeMinutes * 60 * 1000;
const intervals = getCascadeIntervals(timer.cascade);
return {
...timer,
state: 'snoozed',
targetTime: snoozeUntil,
snoozedUntil: snoozeUntil,
snoozeCount: timer.snoozeCount + 1,
warnings: calculateCascadeWarnings(snoozeUntil, intervals.filter(m => m <= snoozeMinutes), now),
};
}
export function dismissTimer(timer: Timer): Timer {
return {
...timer,
state: 'dismissed',
dismissedAt: Date.now(),
};
}
export function completeTimer(timer: Timer): Timer {
return {
...timer,
state: 'completed',
completedAt: Date.now(),
};
}
// ── Pomodoro Transitions ───────────────────────────────────────
export function advancePomodoro(timer: Timer): Timer | null {
if (timer.type !== 'pomodoro' || !timer.pomodoroConfig || !timer.pomodoroState) return null;
const { pomodoroConfig: config, pomodoroState: state } = timer;
const now = Date.now();
if (state.isBreak || state.isLongBreak) {
// Long break finished → all done
if (state.isLongBreak) {
return completeTimer(timer);
}
// Short break finished → start next work round
const nextRound = state.currentRound + 1;
if (nextRound > config.rounds) {
return completeTimer(timer);
}
const durationMs = config.workMinutes * 60 * 1000;
return {
...timer,
state: 'active',
targetTime: now + durationMs,
duration: durationMs,
startedAt: now,
firedAt: null,
elapsedBeforePause: 0,
warnings: calculateCascadeWarnings(now + durationMs, [1], now),
pomodoroState: {
currentRound: nextRound,
isBreak: false,
isLongBreak: false,
completedRounds: state.completedRounds,
},
};
} else {
// Work finished → start break
const completedRounds = state.completedRounds + 1;
const isLongBreak = completedRounds >= config.rounds;
if (isLongBreak && completedRounds >= config.rounds) {
// All rounds done, long break
const durationMs = config.longBreakMinutes * 60 * 1000;
return {
...timer,
state: 'active',
targetTime: now + durationMs,
duration: durationMs,
startedAt: now,
firedAt: null,
elapsedBeforePause: 0,
warnings: [],
pomodoroState: {
currentRound: state.currentRound,
isBreak: false,
isLongBreak: true,
completedRounds,
},
};
}
const durationMs = config.breakMinutes * 60 * 1000;
return {
...timer,
state: 'active',
targetTime: now + durationMs,
duration: durationMs,
startedAt: now,
firedAt: null,
elapsedBeforePause: 0,
warnings: [],
pomodoroState: {
currentRound: state.currentRound,
isBreak: true,
isLongBreak: false,
completedRounds,
},
};
}
}
// ── Utility ────────────────────────────────────────────────────
export function getRemainingMs(timer: Timer, now: number = Date.now()): number {
if (timer.state === 'paused') {
return (timer.duration ?? 0) - timer.elapsedBeforePause;
}
return Math.max(0, timer.targetTime - now);
}
export function isTimerActive(timer: Timer): boolean {
return ['active', 'warning', 'snoozed'].includes(timer.state);
}
export function shouldTimerFire(timer: Timer, now: number = Date.now()): boolean {
if (timer.state === 'snoozed' && timer.snoozedUntil && now >= timer.snoozedUntil) {
return true;
}
if ((timer.state === 'active' || timer.state === 'warning') && now >= timer.targetTime) {
return true;
}
return false;
}