feat: expand utility widgets (#6638)
This commit is contained in:
1 parent
53b518960c
commit
58a24569ce
86 files changed
+7660
-751
No files matched your search
@@ -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}`;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user