630 lines
20 KiB
TypeScript
630 lines
20 KiB
TypeScript
"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<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 = 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 (
|
|
<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 = useI18n("widget.timer");
|
|
const phaseLabel = getTimerPhaseLabel(runtime, t);
|
|
return (
|
|
<ScrollArea h="100%" w="100%">
|
|
<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 style={iconSizes.lg} 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 style={iconSizes.lg} 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 style={iconSizes.sm} />}
|
|
/>
|
|
<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 = useI18n("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 useI18n<"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 = useI18n("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 = useI18n("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}`;
|
|
};
|