"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 { useI18n } from "@homarr/translation/client"; import { iconSizes } from "@homarr/ui"; 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 = useI18n("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( () => ({ 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({ 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(null); const completionSoundTimeoutRef = useRef(null); const attentionFlashTimeoutRef = useRef(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 ( — ); } 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 ; return ( ); } 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 = useI18n("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 ( {phaseLabel} {showCompletedFocus && ( {t("compact.completedFocus", { count: runtime.completedFocusSessions })} )} {formatTimerDuration(remainingMs)} {showProgress && ( )} ); }; interface AdvancedTimerProps extends TimerDisplayProps { durations: TimerDurations; notificationPermission: ReturnType; onNotificationsChange: (enabled: boolean) => Promise; onSoundChange: (enabled: boolean) => void; } const AdvancedTimer = ({ attentionStyle, controlSize, disabled, durations, notificationPermission, onNotificationsChange, onReset, onSoundChange, onToggleRunning, progress, remainingMs, runtime, }: AdvancedTimerProps) => { const t = useI18n("widget.timer"); const phaseLabel = getTimerPhaseLabel(runtime, t); return ( {phaseLabel} {formatTimerDuration(remainingMs)} {runtime.mode === "pomodoro" && } {t("advanced.settings")} {t("advanced.settingsHint")} {t("advanced.localAlerts")} void onNotificationsChange(event.currentTarget.checked)} /> onSoundChange(event.currentTarget.checked)} thumbIcon={} /> {t("advanced.localAlertsHint")} {notificationPermission === "denied" && {t("advanced.notificationsDenied")}} ); }; const TimerControls = ({ controlSize, disabled, isRunning, onReset, onToggleRunning, }: { controlSize: TimerControlSize; disabled: boolean; isRunning: boolean; onReset: () => void; onToggleRunning: () => void; }) => { const t = useI18n("widget.timer"); const toggleLabel = isRunning ? t("action.pause") : t("action.start"); return ( {isRunning ? : } ); }; type ScopedTimerTranslator = ReturnType>; 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 = useI18n("widget.timer"); return ( {t("advanced.sequence")} {Array.from({ length: durations.sessionsBeforeLongBreak }, (_, index) => ( {t("advanced.focusSession", { session: index + 1 })} {t("phase.shortBreak")} ))} {t("phase.longBreak")} ); }; const SettingsSummary = ({ mode, durations }: { mode: TimerRuntimeState["mode"]; durations: TimerDurations }) => { const t = useI18n("widget.timer"); if (mode === "timer") return ; return ( ); }; const SettingLine = ({ label, value }: { label: string; value: number }) => ( {label} {value} ); 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}`; };