feat: expand utility widgets (#6638)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-20 16:26:04 +02:00
1 parent 53b518960c
commit 58a24569ce
86 files changed
+7660 -751

No files matched your search

+628
View File
@@ -0,0 +1,628 @@
"use client";
import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Group,
Paper,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Switch,
Text,
Tooltip,
} from "@mantine/core";
import { useMediaQuery, useReducedMotion } from "@mantine/hooks";
import { IconBell, IconPlayerPause, IconPlayerPlay, IconRefresh, IconSettings, IconVolume } from "@tabler/icons-react";
import { useTiks } from "@rexa-developer/tiks/react";
import { useScopedI18n } from "@homarr/translation/client";
import {
claimBrowserAlertOccurrence,
getBrowserNotificationPermission,
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
import {
createTimerRuntime,
getRemainingMs,
isTimerRuntimeState,
migrateTimerRuntimeState,
pauseTimerRuntime,
reconcileTimerRuntime,
resetTimerRuntime,
setTimerAlertPreference,
startTimerRuntime,
synchronizeTimerRuntime,
} from "./runtime";
import type { TimerDurations, TimerRuntimeState } from "./runtime";
const completionSoundCount = 5;
const completionSoundIntervalMs = 300;
export default function TimerWidget({
boardId,
displayMode,
height,
isEditMode,
itemId,
options,
width,
}: WidgetComponentProps<"timer">) {
const now = useWidgetNow("second");
const t = useScopedI18n("widget.timer");
const reducedMotion = useReducedMotion();
const forcedColors = useMediaQuery("(forced-colors: active)") ?? false;
const { click: playResetSound, toggle: playRunningSound } = useTiks({
theme: "soft",
volume: 0.12,
respectReducedMotion: true,
});
const { notify: playCompletionSound } = useTiks({
theme: "glass",
volume: 0.12,
respectReducedMotion: true,
});
const durations = useMemo<TimerDurations>(
() => ({
timerMinutes: options.timerMinutes,
focusMinutes: options.focusMinutes,
shortBreakMinutes: options.shortBreakMinutes,
longBreakMinutes: options.longBreakMinutes,
sessionsBeforeLongBreak: options.sessionsBeforeLongBreak,
autoStartBreaks: options.autoStartBreaks,
autoStartFocus: options.autoStartFocus,
}),
[
options.autoStartBreaks,
options.autoStartFocus,
options.focusMinutes,
options.longBreakMinutes,
options.sessionsBeforeLongBreak,
options.shortBreakMinutes,
options.timerMinutes,
],
);
const storagePrefix = boardId && itemId ? `homarr:timer:${boardId}:${itemId}` : undefined;
const [runtime, setRuntime] = useLocalWidgetState<TimerRuntimeState>({
key: storagePrefix ? `${storagePrefix}:state` : undefined,
version: 3,
defaultValue: () => createTimerRuntime(options.mode, durations),
validate: isTimerRuntimeState,
migrate: migrateTimerRuntimeState,
});
const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission());
const [attentionFlash, setAttentionFlash] = useState(false);
const historyInitializedRef = useRef(false);
const handledHistoryIdRef = useRef<string | null>(null);
const completionSoundTimeoutRef = useRef<number | null>(null);
const attentionFlashTimeoutRef = useRef<number | null>(null);
const stopCompletionSoundSequence = useCallback(() => {
if (completionSoundTimeoutRef.current !== null) {
window.clearTimeout(completionSoundTimeoutRef.current);
completionSoundTimeoutRef.current = null;
}
if (attentionFlashTimeoutRef.current !== null) {
window.clearTimeout(attentionFlashTimeoutRef.current);
attentionFlashTimeoutRef.current = null;
}
setAttentionFlash(false);
}, []);
const flashAttention = useCallback(() => {
if (attentionFlashTimeoutRef.current !== null) {
window.clearTimeout(attentionFlashTimeoutRef.current);
}
setAttentionFlash(true);
attentionFlashTimeoutRef.current = window.setTimeout(() => {
attentionFlashTimeoutRef.current = null;
setAttentionFlash(false);
}, 180);
}, []);
useEffect(() => {
setRuntime((current) => synchronizeTimerRuntime(current, options.mode, durations));
}, [durations, options.mode, setRuntime]);
useEffect(() => {
if (!now) return;
setRuntime((current) => reconcileTimerRuntime(current, now.getTime(), durations));
}, [durations, now, setRuntime]);
useEffect(() => {
const latest = runtime.history.at(-1);
if (!historyInitializedRef.current) {
historyInitializedRef.current = true;
handledHistoryIdRef.current = latest?.id ?? null;
return;
}
if (!latest || handledHistoryIdRef.current === latest.id) return;
handledHistoryIdRef.current = latest.id;
const alertKey = storagePrefix ? `${storagePrefix}:alerted-completions` : undefined;
if (alertKey && !claimBrowserAlertOccurrence(alertKey, latest.id)) return;
if (runtime.alerts.notifications) {
showBrowserNotification({
title: t("alert.title"),
body: t("alert.completed", { phase: t(`phase.${latest.phase}`) }),
tag: latest.id,
});
}
if (!runtime.alerts.sound) return;
stopCompletionSoundSequence();
playCompletionSound();
if (!runtime.awaitingManualStart) return;
flashAttention();
let remainingSounds = completionSoundCount - 1;
const playNextSound = () => {
completionSoundTimeoutRef.current = window.setTimeout(() => {
completionSoundTimeoutRef.current = null;
playCompletionSound();
flashAttention();
remainingSounds -= 1;
if (remainingSounds > 0) playNextSound();
}, completionSoundIntervalMs);
};
playNextSound();
}, [
playCompletionSound,
flashAttention,
runtime.alerts.notifications,
runtime.alerts.sound,
runtime.awaitingManualStart,
runtime.history,
stopCompletionSoundSequence,
storagePrefix,
t,
]);
useEffect(() => {
if (runtime.awaitingManualStart && runtime.alerts.sound) return;
stopCompletionSoundSequence();
}, [runtime.alerts.sound, runtime.awaitingManualStart, stopCompletionSoundSequence]);
useEffect(() => stopCompletionSoundSequence, [stopCompletionSoundSequence]);
if (!now) {
return (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const nowMs = now.getTime();
const remainingMs = getRemainingMs(runtime, nowMs);
const phaseDurationMs = runtime.totalDurationMs;
let progress = 0;
if (phaseDurationMs > 0) {
progress = Math.min(100, Math.max(0, ((phaseDurationMs - remainingMs) / phaseDurationMs) * 100));
}
const controlSize = getTimerControlSize(width, height, displayMode);
const attentionStyle = getTimerAttentionStyle({
awaitingManualStart: runtime.awaitingManualStart,
flash: attentionFlash,
forcedColors,
reducedMotion,
});
const toggleRunning = () => {
if (runtime.alerts.sound) playRunningSound(runtime.status !== "running");
if (runtime.status === "running") {
setRuntime((current) => pauseTimerRuntime(current, nowMs));
return;
}
setRuntime((current) => startTimerRuntime(current, nowMs, durations));
};
const reset = () => {
if (runtime.alerts.sound) playResetSound();
setRuntime((current) => resetTimerRuntime(current, durations));
};
const setNotifications = async (enabled: boolean) => {
if (!enabled) {
setRuntime((current) => setTimerAlertPreference(current, "notifications", false));
return;
}
const permission = await requestBrowserNotificationPermission();
setNotificationPermission(permission);
if (permission === "granted") {
setRuntime((current) => setTimerAlertPreference(current, "notifications", true));
}
};
const setSound = (enabled: boolean) => {
playRunningSound(enabled);
setRuntime((current) => setTimerAlertPreference(current, "sound", enabled));
};
const shared = {
disabled: isEditMode,
attentionStyle,
controlSize,
height,
onReset: reset,
onToggleRunning: toggleRunning,
progress,
remainingMs,
runtime,
width,
};
if (displayMode !== "advanced") return <CompactTimer {...shared} />;
return (
<AdvancedTimer
{...shared}
durations={durations}
notificationPermission={notificationPermission}
onNotificationsChange={setNotifications}
onSoundChange={setSound}
/>
);
}
type TimerControlSize = "sm" | "md" | "lg";
interface TimerDisplayProps {
attentionStyle: CSSProperties;
controlSize: TimerControlSize;
disabled: boolean;
height: number;
onReset: () => void;
onToggleRunning: () => void;
progress: number;
remainingMs: number;
runtime: TimerRuntimeState;
width: number;
}
const CompactTimer = ({
attentionStyle,
controlSize,
disabled,
height,
onReset,
onToggleRunning,
progress,
remainingMs,
runtime,
width,
}: TimerDisplayProps) => {
const t = useScopedI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t);
const showCompletedFocus = runtime.mode === "pomodoro" && width >= 320;
const showProgress = height >= 125;
let phaseBadgeMaxWidth = "calc(100% - var(--mantine-spacing-md))";
if (showCompletedFocus) phaseBadgeMaxWidth = "60%";
return (
<Stack
h="100%"
w="100%"
align="center"
justify="center"
gap="xs"
p="xs"
style={{ ...attentionStyle, overflow: "hidden", position: "relative" }}
>
<Badge
variant="light"
size="xs"
h="auto"
maw={phaseBadgeMaxWidth}
py="calc(var(--mantine-spacing-xs) / 2)"
aria-live="polite"
style={{
insetInlineStart: "var(--mantine-spacing-xs)",
position: "absolute",
top: "var(--mantine-spacing-xs)",
}}
styles={{
label: {
lineHeight: "var(--mantine-line-height-xs)",
overflow: "visible",
textOverflow: "clip",
whiteSpace: "normal",
},
}}
>
{phaseLabel}
</Badge>
{showCompletedFocus && (
<Text
size="xs"
c="dimmed"
style={{
insetInlineEnd: "var(--mantine-spacing-xs)",
position: "absolute",
top: "var(--mantine-spacing-xs)",
}}
>
{t("compact.completedFocus", { count: runtime.completedFocusSessions })}
</Text>
)}
<Text component="time" fz="xl" fw={700} lh={1} style={{ fontVariantNumeric: "tabular-nums" }}>
{formatTimerDuration(remainingMs)}
</Text>
{showProgress && (
<Progress w="100%" value={progress} aria-label={t("progress", { progress: Math.round(progress) })} />
)}
<TimerControls
controlSize={controlSize}
disabled={disabled}
isRunning={runtime.status === "running"}
onReset={onReset}
onToggleRunning={onToggleRunning}
/>
</Stack>
);
};
interface AdvancedTimerProps extends TimerDisplayProps {
durations: TimerDurations;
notificationPermission: ReturnType<typeof getBrowserNotificationPermission>;
onNotificationsChange: (enabled: boolean) => Promise<void>;
onSoundChange: (enabled: boolean) => void;
}
const AdvancedTimer = ({
attentionStyle,
controlSize,
disabled,
durations,
notificationPermission,
onNotificationsChange,
onReset,
onSoundChange,
onToggleRunning,
progress,
remainingMs,
runtime,
}: AdvancedTimerProps) => {
const t = useScopedI18n("widget.timer");
const phaseLabel = getTimerPhaseLabel(runtime, t);
return (
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
<Stack gap="md" p="md">
<Paper
withBorder
p="lg"
style={{
...attentionStyle,
background:
"linear-gradient(145deg, var(--mantine-primary-color-light), var(--mantine-color-secondaryColor-light))",
}}
>
<Stack align="center" gap="sm">
<Badge variant="light" size="sm" aria-live="polite">
{phaseLabel}
</Badge>
<Text
component="time"
fz="var(--mantine-h1-font-size)"
fw={700}
lh={1}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{formatTimerDuration(remainingMs)}
</Text>
<Progress w="100%" value={progress} aria-label={t("progress", { progress: Math.round(progress) })} />
<TimerControls
controlSize={controlSize}
disabled={disabled}
isRunning={runtime.status === "running"}
onReset={onReset}
onToggleRunning={onToggleRunning}
/>
</Stack>
</Paper>
{runtime.mode === "pomodoro" && <PomodoroSequence runtime={runtime} durations={durations} />}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<Paper withBorder p="md">
<Group gap="xs" mb="sm">
<IconSettings size={18} aria-hidden />
<Text fw={600}>{t("advanced.settings")}</Text>
</Group>
<SettingsSummary mode={runtime.mode} durations={durations} />
<Text size="xs" c="dimmed" mt="sm">
{t("advanced.settingsHint")}
</Text>
</Paper>
<Paper withBorder p="md">
<Group gap="xs" mb="sm">
<IconBell size={18} aria-hidden />
<Text fw={600}>{t("advanced.localAlerts")}</Text>
</Group>
<Stack gap="sm">
<Switch
checked={runtime.alerts.notifications && notificationPermission === "granted"}
disabled={disabled || notificationPermission === "unsupported" || notificationPermission === "denied"}
label={t("advanced.notifications")}
onChange={(event) => void onNotificationsChange(event.currentTarget.checked)}
/>
<Switch
checked={runtime.alerts.sound}
disabled={disabled}
label={t("advanced.sound")}
onChange={(event) => onSoundChange(event.currentTarget.checked)}
thumbIcon={<IconVolume size={12} />}
/>
<Text size="xs" c="dimmed">
{t("advanced.localAlertsHint")}
</Text>
{notificationPermission === "denied" && <Alert color="yellow">{t("advanced.notificationsDenied")}</Alert>}
</Stack>
</Paper>
</SimpleGrid>
</Stack>
</ScrollArea>
);
};
const TimerControls = ({
controlSize,
disabled,
isRunning,
onReset,
onToggleRunning,
}: {
controlSize: TimerControlSize;
disabled: boolean;
isRunning: boolean;
onReset: () => void;
onToggleRunning: () => void;
}) => {
const t = useScopedI18n("widget.timer");
const toggleLabel = isRunning ? t("action.pause") : t("action.start");
return (
<Group gap="xs" wrap="nowrap">
<Tooltip label={toggleLabel}>
<ActionIcon
size={controlSize}
variant="filled"
disabled={disabled}
onClick={onToggleRunning}
aria-label={toggleLabel}
>
{isRunning ? <IconPlayerPause /> : <IconPlayerPlay />}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.reset")}>
<ActionIcon
size={controlSize}
variant="light"
disabled={disabled}
onClick={onReset}
aria-label={t("action.reset")}
>
<IconRefresh />
</ActionIcon>
</Tooltip>
</Group>
);
};
type ScopedTimerTranslator = ReturnType<typeof useScopedI18n<"widget.timer">>;
const getTimerPhaseLabel = (runtime: TimerRuntimeState, t: ScopedTimerTranslator) => {
const phase = t(`phase.${runtime.phase}`);
if (!runtime.awaitingManualStart) return phase;
if (runtime.mode === "timer") return t("attention.timerComplete");
return t("attention.phaseReady", { phase });
};
const getTimerControlSize = (
width: number,
height: number,
displayMode: "compact" | "advanced" | undefined,
): TimerControlSize => {
if (displayMode === "advanced") return "lg";
if (width < 150 || height < 115) return "sm";
if (width < 220 || height < 150) return "md";
return "lg";
};
const getTimerAttentionStyle = ({
awaitingManualStart,
flash,
forcedColors,
reducedMotion,
}: {
awaitingManualStart: boolean;
flash: boolean;
forcedColors: boolean;
reducedMotion: boolean;
}): CSSProperties => {
if (forcedColors && awaitingManualStart) {
return {
outline: "0.125rem solid Highlight",
outlineOffset: "0.125rem",
};
}
if (reducedMotion && awaitingManualStart) {
return {
boxShadow:
"inset 0 0 1rem 0.125rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 45%, transparent)",
};
}
if (!flash) {
return {
boxShadow: "none",
transition: "box-shadow 0.12s ease-out",
};
}
return {
boxShadow:
"inset 0 0 1rem 0.125rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 45%, transparent), 0 0 3rem 0.875rem color-mix(in srgb, var(--mantine-color-secondaryColor-filled) 40%, transparent)",
transition: "box-shadow 0.12s ease-out",
};
};
const PomodoroSequence = ({ runtime, durations }: { runtime: TimerRuntimeState; durations: TimerDurations }) => {
const t = useScopedI18n("widget.timer");
return (
<Paper withBorder p="md">
<Text fw={600} mb="xs">
{t("advanced.sequence")}
</Text>
<Group gap="xs" wrap="wrap">
{Array.from({ length: durations.sessionsBeforeLongBreak }, (_, index) => (
<Group key={index} gap={4} wrap="nowrap">
<Badge variant={index < runtime.completedFocusSessions ? "filled" : "light"}>
{t("advanced.focusSession", { session: index + 1 })}
</Badge>
<Text size="xs" c="dimmed">
{t("phase.shortBreak")}
</Text>
</Group>
))}
<Badge color="violet" variant={runtime.phase === "longBreak" ? "filled" : "light"}>
{t("phase.longBreak")}
</Badge>
</Group>
</Paper>
);
};
const SettingsSummary = ({ mode, durations }: { mode: TimerRuntimeState["mode"]; durations: TimerDurations }) => {
const t = useScopedI18n("widget.timer");
if (mode === "timer") return <SettingLine label={t("option.timerMinutes.label")} value={durations.timerMinutes} />;
return (
<Stack gap={5}>
<SettingLine label={t("option.focusMinutes.label")} value={durations.focusMinutes} />
<SettingLine label={t("option.shortBreakMinutes.label")} value={durations.shortBreakMinutes} />
<SettingLine label={t("option.longBreakMinutes.label")} value={durations.longBreakMinutes} />
<SettingLine label={t("option.sessionsBeforeLongBreak.label")} value={durations.sessionsBeforeLongBreak} />
</Stack>
);
};
const SettingLine = ({ label, value }: { label: string; value: number }) => (
<Group justify="space-between" gap="md" wrap="nowrap">
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value}
</Text>
</Group>
);
export const formatTimerDuration = (durationMs: number): string => {
const totalSeconds = Math.max(0, Math.ceil(durationMs / 1_000));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const minuteText = String(minutes).padStart(2, "0");
const secondText = String(seconds).padStart(2, "0");
if (hours === 0) return `${minuteText}:${secondText}`;
return `${String(hours).padStart(2, "0")}:${minuteText}:${secondText}`;
};
+54
View File
@@ -0,0 +1,54 @@
import { IconClockPlay } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("timer", {
icon: IconClockPlay,
supportsAdvancedFocus: true,
createOptions() {
return optionsBuilder.from(
(factory) => ({
mode: factory.select({
defaultValue: "pomodoro",
options: [
{ value: "timer", label: (t) => t("widget.timer.option.mode.options.timer") },
{ value: "pomodoro", label: (t) => t("widget.timer.option.mode.options.pomodoro") },
],
}),
timerMinutes: factory.number({
defaultValue: 10,
validate: z.number().int().min(1).max(1440),
}),
focusMinutes: factory.number({
defaultValue: 25,
validate: z.number().int().min(1).max(180),
}),
shortBreakMinutes: factory.number({
defaultValue: 5,
validate: z.number().int().min(1).max(60),
}),
longBreakMinutes: factory.number({
defaultValue: 15,
validate: z.number().int().min(1).max(180),
}),
sessionsBeforeLongBreak: factory.number({
defaultValue: 4,
validate: z.number().int().min(1).max(12),
}),
autoStartBreaks: factory.switch({ defaultValue: false }),
autoStartFocus: factory.switch({ defaultValue: false }),
}),
{
timerMinutes: { shouldHide: ({ mode }) => mode !== "timer" },
focusMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
shortBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
longBreakMinutes: { shouldHide: ({ mode }) => mode !== "pomodoro" },
sessionsBeforeLongBreak: { shouldHide: ({ mode }) => mode !== "pomodoro" },
autoStartBreaks: { shouldHide: ({ mode }) => mode !== "pomodoro" },
autoStartFocus: { shouldHide: ({ mode }) => mode !== "pomodoro" },
},
);
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
createTimerRuntime,
getRemainingMs,
isTimerRuntimeState,
pauseTimerRuntime,
reconcileTimerRuntime,
startTimerRuntime,
synchronizeTimerRuntime,
} from "./runtime";
const durations = {
timerMinutes: 10,
focusMinutes: 25,
shortBreakMinutes: 5,
longBreakMinutes: 15,
sessionsBeforeLongBreak: 2,
autoStartBreaks: false,
autoStartFocus: false,
};
describe("timer runtime", () => {
it("uses a persisted deadline and preserves a paused remainder", () => {
const initial = createTimerRuntime("timer", durations);
const running = startTimerRuntime(initial, 1_000, durations);
expect(running.deadline).toBe(601_000);
expect(getRemainingMs(running, 61_000)).toBe(540_000);
const paused = pauseTimerRuntime(running, 61_000);
expect(paused).toMatchObject({ status: "paused", remainingMs: 540_000, deadline: null });
expect(startTimerRuntime(paused, 100_000, durations).deadline).toBe(640_000);
});
it("moves through short and long breaks using completed focus sessions", () => {
const firstFocus = startTimerRuntime(createTimerRuntime("pomodoro", durations), 0, durations);
const shortBreak = reconcileTimerRuntime(firstFocus, 25 * 60_000, durations);
expect(shortBreak).toMatchObject({ phase: "shortBreak", status: "idle", completedFocusSessions: 1 });
const secondFocus = startTimerRuntime(
{ ...shortBreak, phase: "focus", remainingMs: 25 * 60_000 },
2_000_000,
durations,
);
const longBreak = reconcileTimerRuntime(secondFocus, 3_500_000, durations);
expect(longBreak).toMatchObject({ phase: "longBreak", status: "idle", completedFocusSessions: 2 });
});
it("catches up chained automatic phases from their original deadlines", () => {
const automatic = {
...durations,
focusMinutes: 1,
shortBreakMinutes: 1,
autoStartBreaks: true,
autoStartFocus: true,
};
const running = startTimerRuntime(createTimerRuntime("pomodoro", automatic), 0, automatic);
const reconciled = reconcileTimerRuntime(running, 125_000, automatic);
expect(reconciled).toMatchObject({ phase: "focus", status: "running", completedFocusSessions: 1 });
expect(reconciled.deadline).toBe(180_000);
expect(reconciled.history).toHaveLength(2);
});
it("keeps only the latest 20 completions", () => {
let state = startTimerRuntime(createTimerRuntime("timer", { ...durations, timerMinutes: 1 }), 0, durations);
for (let index = 1; index <= 25; index += 1) {
state = reconcileTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 });
state = startTimerRuntime(state, index * 60_000, { ...durations, timerMinutes: 1 });
}
expect(state.history).toHaveLength(20);
});
it("resets only live state when the permanent mode changes", () => {
const initial = createTimerRuntime("timer", durations);
const withHistory = {
...initial,
history: [{ id: "timer:1", phase: "timer" as const, completedAt: 1, durationMs: 1 }],
};
const synchronized = synchronizeTimerRuntime(withHistory, "pomodoro", durations);
expect(synchronized).toMatchObject({ mode: "pomodoro", phase: "focus", status: "idle" });
expect(synchronized.history).toEqual(withHistory.history);
});
it("rejects inconsistent stored deadlines and modes", () => {
const valid = createTimerRuntime("pomodoro", durations);
expect(isTimerRuntimeState(valid)).toBe(true);
expect(isTimerRuntimeState({ ...valid, status: "running", deadline: null })).toBe(false);
expect(isTimerRuntimeState({ ...valid, phase: "timer" })).toBe(false);
});
});
+287
View File
@@ -0,0 +1,287 @@
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);