Files
homarr/packages/widgets/src/timer/runtime.ts
T

288 lines
9.7 KiB
TypeScript

export type TimerMode = "timer" | "pomodoro";
export type TimerPhase = "timer" | "focus" | "shortBreak" | "longBreak";
export type TimerStatus = "idle" | "running" | "paused";
export interface TimerDurations {
timerMinutes: number;
focusMinutes: number;
shortBreakMinutes: number;
longBreakMinutes: number;
sessionsBeforeLongBreak: number;
autoStartBreaks: boolean;
autoStartFocus: boolean;
}
export interface TimerHistoryEntry {
id: string;
phase: TimerPhase;
completedAt: number;
durationMs: number;
}
export interface TimerAlertPreferences {
notifications: boolean;
sound: boolean;
}
export interface TimerRuntimeState {
mode: TimerMode;
phase: TimerPhase;
status: TimerStatus;
remainingMs: number;
totalDurationMs: number;
deadline: number | null;
completedFocusSessions: number;
history: TimerHistoryEntry[];
alerts: TimerAlertPreferences;
awaitingManualStart: boolean;
}
const maximumHistoryEntries = 20;
const maximumReconciledPhases = 100;
const maximumDurationMs = 24 * 60 * 60_000;
export const createTimerRuntime = (mode: TimerMode, durations: TimerDurations): TimerRuntimeState => {
const phase = getInitialPhase(mode);
return {
mode,
phase,
status: "idle",
remainingMs: getPhaseDurationMs(phase, durations),
totalDurationMs: getPhaseDurationMs(phase, durations),
deadline: null,
completedFocusSessions: 0,
history: [],
alerts: { notifications: false, sound: true },
awaitingManualStart: false,
};
};
export const isTimerRuntimeState = (value: unknown): value is TimerRuntimeState => {
if (!isRecord(value)) return false;
if (value.mode !== "timer" && value.mode !== "pomodoro") return false;
if (!isTimerPhase(value.phase) || !isTimerStatus(value.status)) return false;
if (value.mode === "timer" && value.phase !== "timer") return false;
if (value.mode === "pomodoro" && value.phase === "timer") return false;
if (!isFiniteNumber(value.remainingMs) || value.remainingMs < 0) return false;
if (!isFiniteNumber(value.totalDurationMs) || value.totalDurationMs <= 0) return false;
if (value.totalDurationMs > maximumDurationMs || value.remainingMs > value.totalDurationMs) return false;
if (value.deadline !== null && !isFiniteNumber(value.deadline)) return false;
if (value.status === "running" && value.deadline === null) return false;
if (value.status !== "running" && value.deadline !== null) return false;
if (!isFiniteNumber(value.completedFocusSessions) || !Number.isInteger(value.completedFocusSessions)) return false;
if (value.completedFocusSessions < 0 || value.completedFocusSessions > 12) return false;
if (!Array.isArray(value.history) || value.history.length > maximumHistoryEntries) return false;
if (!value.history.every(isTimerHistoryEntry)) return false;
if (!isRecord(value.alerts)) return false;
if (typeof value.alerts.notifications !== "boolean" || typeof value.alerts.sound !== "boolean") return false;
return typeof value.awaitingManualStart === "boolean";
};
export const migrateTimerRuntimeState = (value: unknown, storedVersion: number): TimerRuntimeState | undefined => {
if ((storedVersion !== 1 && storedVersion !== 2) || !isRecord(value) || !isRecord(value.alerts)) return undefined;
let awaitingManualStart = false;
if (storedVersion === 2 && typeof value.awaitingManualStart === "boolean") {
awaitingManualStart = value.awaitingManualStart;
}
const migrated = {
...value,
alerts: { ...value.alerts, sound: true },
awaitingManualStart,
};
if (!isTimerRuntimeState(migrated)) return undefined;
return migrated;
};
export const getPhaseDurationMs = (phase: TimerPhase, durations: TimerDurations): number => {
if (phase === "timer") return durations.timerMinutes * 60_000;
if (phase === "focus") return durations.focusMinutes * 60_000;
if (phase === "shortBreak") return durations.shortBreakMinutes * 60_000;
return durations.longBreakMinutes * 60_000;
};
export const getRemainingMs = (state: TimerRuntimeState, now: number): number => {
if (state.status !== "running" || state.deadline === null) return state.remainingMs;
return Math.max(0, state.deadline - now);
};
export const startTimerRuntime = (
state: TimerRuntimeState,
now: number,
durations: TimerDurations,
): TimerRuntimeState => {
if (state.status === "running") return state;
const remainingMs = state.remainingMs > 0 ? state.remainingMs : getPhaseDurationMs(state.phase, durations);
const totalDurationMs = state.remainingMs > 0 ? state.totalDurationMs : remainingMs;
return {
...state,
status: "running",
remainingMs,
totalDurationMs,
deadline: now + remainingMs,
awaitingManualStart: false,
};
};
export const pauseTimerRuntime = (state: TimerRuntimeState, now: number): TimerRuntimeState => {
if (state.status !== "running") return state;
return { ...state, status: "paused", remainingMs: getRemainingMs(state, now), deadline: null };
};
export const resetTimerRuntime = (state: TimerRuntimeState, durations: TimerDurations): TimerRuntimeState => {
const phase = getInitialPhase(state.mode);
return {
...state,
phase,
status: "idle",
remainingMs: getPhaseDurationMs(phase, durations),
totalDurationMs: getPhaseDurationMs(phase, durations),
deadline: null,
completedFocusSessions: 0,
awaitingManualStart: false,
};
};
export const synchronizeTimerRuntime = (
state: TimerRuntimeState,
mode: TimerMode,
durations: TimerDurations,
): TimerRuntimeState => {
if (state.mode !== mode) {
const initial = createTimerRuntime(mode, durations);
return { ...initial, history: state.history, alerts: state.alerts };
}
if (state.status !== "idle") return state;
const remainingMs = getPhaseDurationMs(state.phase, durations);
const completedFocusSessions = Math.min(state.completedFocusSessions, durations.sessionsBeforeLongBreak - 1);
if (
remainingMs === state.remainingMs &&
remainingMs === state.totalDurationMs &&
completedFocusSessions === state.completedFocusSessions
) {
return state;
}
return { ...state, remainingMs, totalDurationMs: remainingMs, completedFocusSessions };
};
export const reconcileTimerRuntime = (
state: TimerRuntimeState,
now: number,
durations: TimerDurations,
): TimerRuntimeState => {
let next = state;
let reconciled = 0;
while (next.status === "running" && next.deadline !== null && next.deadline <= now) {
next = completeTimerPhase(next, next.deadline, durations);
reconciled += 1;
if (reconciled >= maximumReconciledPhases) break;
}
if (reconciled < maximumReconciledPhases) return next;
if (next.status !== "running") return next;
const remainingMs = getPhaseDurationMs(next.phase, durations);
return { ...next, remainingMs, totalDurationMs: remainingMs, deadline: now + remainingMs };
};
export const setTimerAlertPreference = (
state: TimerRuntimeState,
preference: keyof TimerAlertPreferences,
enabled: boolean,
): TimerRuntimeState => ({
...state,
alerts: { ...state.alerts, [preference]: enabled },
});
const completeTimerPhase = (
state: TimerRuntimeState,
completedAt: number,
durations: TimerDurations,
): TimerRuntimeState => {
const durationMs = state.totalDurationMs;
const history = [
...state.history,
{ id: `${state.phase}:${completedAt}`, phase: state.phase, completedAt, durationMs },
].slice(-maximumHistoryEntries);
if (state.mode === "timer") {
return {
...state,
status: "idle",
remainingMs: getPhaseDurationMs(state.phase, durations),
totalDurationMs: getPhaseDurationMs(state.phase, durations),
deadline: null,
history,
awaitingManualStart: true,
};
}
if (state.phase === "focus") {
const completedFocusSessions = state.completedFocusSessions + 1;
const isLongBreak = completedFocusSessions >= durations.sessionsBeforeLongBreak;
const phase: TimerPhase = isLongBreak ? "longBreak" : "shortBreak";
return startNextPhase(
{ ...state, phase, completedFocusSessions, history },
completedAt,
durations,
durations.autoStartBreaks,
);
}
const completedFocusSessions = state.phase === "longBreak" ? 0 : state.completedFocusSessions;
return startNextPhase(
{ ...state, phase: "focus", completedFocusSessions, history },
completedAt,
durations,
durations.autoStartFocus,
);
};
const startNextPhase = (
state: TimerRuntimeState,
completedAt: number,
durations: TimerDurations,
autoStart: boolean,
): TimerRuntimeState => {
const remainingMs = getPhaseDurationMs(state.phase, durations);
if (!autoStart) {
return {
...state,
status: "idle",
remainingMs,
totalDurationMs: remainingMs,
deadline: null,
awaitingManualStart: true,
};
}
return {
...state,
status: "running",
remainingMs,
totalDurationMs: remainingMs,
deadline: completedAt + remainingMs,
awaitingManualStart: false,
};
};
const getInitialPhase = (mode: TimerMode): TimerPhase => {
if (mode === "timer") return "timer";
return "focus";
};
const isTimerPhase = (value: unknown): value is TimerPhase =>
value === "timer" || value === "focus" || value === "shortBreak" || value === "longBreak";
const isTimerStatus = (value: unknown): value is TimerStatus =>
value === "idle" || value === "running" || value === "paused";
const isTimerHistoryEntry = (value: unknown): value is TimerHistoryEntry => {
if (!isRecord(value) || typeof value.id !== "string" || !isTimerPhase(value.phase)) return false;
return isFiniteNumber(value.completedAt) && isFiniteNumber(value.durationMs) && value.durationMs > 0;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);