"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, Badge, Group, Paper, Progress, ScrollArea, SimpleGrid, Stack, Switch, Text, ThemeIcon, Title, } from "@mantine/core"; import { IconBell, IconBellOff, IconHourglass } from "@tabler/icons-react"; import { useCurrentIntlLocale, 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 { formatCountdownDuration } from "./display"; import styles from "./component.module.css"; import { getCountdownEventState, getCountdownProgress, groupCountdownStates } from "./recurrence"; import type { CountdownEventState } from "./recurrence"; interface AlertPreferences { notifications: boolean; } const defaultAlertPreferences: AlertPreferences = { notifications: false }; const isAlertPreferences = (value: unknown): value is AlertPreferences => { if (typeof value !== "object" || value === null) return false; return "notifications" in value && typeof value.notifications === "boolean"; }; const migrateAlertPreferences = (value: unknown, storedVersion: number): AlertPreferences | undefined => { if (storedVersion !== 1 || !isAlertPreferences(value)) return undefined; return { notifications: value.notifications }; }; export default function CountdownWidget({ boardId, displayMode, isEditMode, itemId, options, width, }: WidgetComponentProps<"countdown">) { const now = useWidgetNow(options.showSeconds ? "second" : "minute"); const locale = useCurrentIntlLocale(); const t = useScopedI18n("widget.countdown"); const storagePrefix = boardId && itemId ? `homarr:countdown:${boardId}:${itemId}` : undefined; const [preferences, setPreferences] = useLocalWidgetState({ key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined, version: 2, defaultValue: defaultAlertPreferences, validate: isAlertPreferences, migrate: migrateAlertPreferences, }); const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission()); const previousNowRef = useRef(null); const states = useMemo(() => { if (!now) return []; return options.events.map((event) => getCountdownEventState(event, now)); }, [now, options.events]); const grouped = useMemo(() => groupCountdownStates(states), [states]); useEffect(() => { if (!now || !storagePrefix) return; const previousNow = previousNowRef.current; previousNowRef.current = now; if (!previousNow || !preferences.notifications) return; states.forEach((state) => { const occurrence = state.previousOccurrence; if (!occurrence || occurrence <= previousNow || occurrence > now) return; const claimKey = `${storagePrefix}:alerted-occurrences`; const occurrenceKey = `${state.event.id}:${occurrence.toISOString()}`; if (!claimBrowserAlertOccurrence(claimKey, occurrenceKey)) return; showBrowserNotification({ title: state.event.label, body: t("alert.reached"), tag: occurrenceKey, }); }); }, [now, preferences.notifications, states, storagePrefix, t]); if (!now) { return ( — ); } const hero = states[0]; if (!hero) { return ( {t("empty.title")} {t("empty.description")} ); } let heroStatus: string | null = null; if (hero.invalid) heroStatus = t("invalid.value"); else if (hero.completed) heroStatus = t("completed"); if (displayMode !== "advanced") { return ( {heroStatus && ( {heroStatus} )} {hero.event.label} {formatOccurrence(hero, locale)} {options.showProgress && ( )} ); } const setNotifications = async (enabled: boolean) => { if (!enabled) { setPreferences((current) => ({ ...current, notifications: false })); return; } const permission = await requestBrowserNotificationPermission(); setNotificationPermission(permission); if (permission === "granted") setPreferences((current) => ({ ...current, notifications: true })); }; const eventColumns = width >= 700 ? 2 : 1; return ( {heroStatus && {heroStatus}} {formatOccurrence(hero, locale)} {hero.event.label} {options.showProgress && } {grouped.invalid.length > 0 && ( {t("invalid.description", { count: grouped.invalid.length })} )} ); } const CountdownValue = ({ state, now, locale, showSeconds, compact = false, }: { state: CountdownEventState; now: Date; locale: string; showSeconds: boolean; compact?: boolean; }) => { const t = useScopedI18n("widget.countdown"); let value = t("invalid.value"); if (state.completed) value = t("completed"); else if (state.nextOccurrence) value = formatCountdownDuration(state, now, showSeconds, locale, compact ? "narrow" : "short"); return ( {value} ); }; const ProgressBar = ({ className, state, now, label, }: { className?: string; state: CountdownEventState; now: Date; label: string; }) => { const progress = getCountdownProgress(state, now); if (progress === null) return null; return ( ); }; const EventSection = ({ title, states, now, locale, showSeconds, showProgress, columns, }: { title: string; states: CountdownEventState[]; now: Date; locale: string; showSeconds: boolean; showProgress: boolean; columns: number; }) => { const t = useScopedI18n("widget.countdown"); if (states.length === 0) return null; return ( {title} {states.map((state) => ( {state.event.label} {state.event.recurrence === "yearly" && {t("yearly")}} {showProgress && } {formatOccurrence(state, locale)} ))} ); }; const AlertControls = ({ disabled, persistent, preferences, notificationPermission, onNotificationsChange, }: { disabled: boolean; persistent: boolean; preferences: AlertPreferences; notificationPermission: ReturnType; onNotificationsChange: (enabled: boolean) => Promise; }) => { const t = useScopedI18n("widget.countdown.alert"); if (!persistent) return null; return ( void onNotificationsChange(event.currentTarget.checked)} label={t("notifications")} thumbIcon={preferences.notifications ? : } /> {notificationPermission === "denied" && ( {t("permissionDenied")} )} ); }; const formatOccurrence = (state: CountdownEventState, locale: string) => { const occurrence = state.nextOccurrence ?? state.previousOccurrence; if (!occurrence) return state.event.timeZone; return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: state.event.timeZone, }).format(occurrence); };