425 lines
12 KiB
TypeScript
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;
|
|
}
|