Files
homarr/packages/widgets/src/countdown/component.tsx
T
2026-08-20 15:16:26 +02:00

362 lines
11 KiB
TypeScript

"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<AlertPreferences>({
key: storagePrefix ? `${storagePrefix}:alert-preferences` : undefined,
version: 2,
defaultValue: defaultAlertPreferences,
validate: isAlertPreferences,
migrate: migrateAlertPreferences,
});
const [notificationPermission, setNotificationPermission] = useState(() => getBrowserNotificationPermission());
const previousNowRef = useRef<Date | null>(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 (
<Stack h="100%" align="center" justify="center">
<Text c="dimmed">—</Text>
</Stack>
);
}
const hero = states[0];
if (!hero) {
return (
<Stack h="100%" align="center" justify="center" gap="xs" p="md">
<ThemeIcon variant="light" size="xl" radius="xl">
<IconHourglass />
</ThemeIcon>
<Text fw={600}>{t("empty.title")}</Text>
<Text size="sm" c="dimmed" ta="center">
{t("empty.description")}
</Text>
</Stack>
);
}
let heroStatus: string | null = null;
if (hero.invalid) heroStatus = t("invalid.value");
else if (hero.completed) heroStatus = t("completed");
if (displayMode !== "advanced") {
return (
<Stack className={styles.root} h="100%" justify="center" align="center" gap={6} p="sm">
<Group gap="xs" wrap="nowrap" maw="100%">
{heroStatus && (
<Badge className={styles.compactStatus} variant="outline" size="xs" flex="0 0 auto">
{heroStatus}
</Badge>
)}
<Text size="sm" fw={600} truncate>
{hero.event.label}
</Text>
</Group>
<CountdownValue state={hero} now={now} locale={locale} showSeconds={options.showSeconds} compact />
<Text className={styles.compactTarget} size="xs" c="dimmed" truncate maw="100%">
{formatOccurrence(hero, locale)}
</Text>
{options.showProgress && (
<ProgressBar className={styles.compactProgress} state={hero} now={now} label={t("progress")} />
)}
</Stack>
);
}
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 (
<Stack className={styles.root} h="100%" gap="md" p="md">
<Paper withBorder p="lg" radius="md">
<Stack gap="md">
<Group gap="xs" wrap="nowrap">
{heroStatus && <Badge variant="outline">{heroStatus}</Badge>}
<Text size="sm" c="dimmed" truncate>
{formatOccurrence(hero, locale)}
</Text>
</Group>
<Title order={2} lineClamp={1}>
{hero.event.label}
</Title>
<CountdownValue state={hero} now={now} locale={locale} showSeconds={options.showSeconds} />
</Stack>
{options.showProgress && <ProgressBar state={hero} now={now} label={t("progress")} />}
</Paper>
<AlertControls
disabled={isEditMode}
persistent={storagePrefix !== undefined}
preferences={preferences}
notificationPermission={notificationPermission}
onNotificationsChange={setNotifications}
/>
<ScrollArea type="auto" style={{ flex: 1, minHeight: 0 }}>
<Stack gap="md" pb="xs">
<EventSection
title={t("upcoming")}
states={grouped.upcoming}
now={now}
locale={locale}
showSeconds={options.showSeconds}
showProgress={options.showProgress}
columns={eventColumns}
/>
<EventSection
title={t("completedEvents")}
states={grouped.completed}
now={now}
locale={locale}
showSeconds={options.showSeconds}
showProgress={options.showProgress}
columns={eventColumns}
/>
{grouped.invalid.length > 0 && (
<Alert color="yellow" title={t("invalid.title")}>
{t("invalid.description", { count: grouped.invalid.length })}
</Alert>
)}
</Stack>
</ScrollArea>
</Stack>
);
}
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 (
<Text
className={compact ? `${styles.duration} ${styles.compactDuration}` : styles.duration}
fz={compact ? undefined : 42}
fw={750}
lh={1.05}
ta={compact ? "center" : undefined}
>
{value}
</Text>
);
};
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 (
<Progress
className={className}
mt="xs"
value={progress}
aria-label={`${label}: ${Math.round(progress)}%`}
animated={false}
/>
);
};
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 (
<Stack gap="xs">
<Text fw={700}>{title}</Text>
<SimpleGrid cols={columns} spacing="xs">
{states.map((state) => (
<Paper className={styles.eventCard} key={state.event.id} withBorder p="sm">
<Stack gap={4} h="100%">
<Group justify="space-between" wrap="nowrap">
<Text fw={600} truncate>
{state.event.label}
</Text>
{state.event.recurrence === "yearly" && <Badge variant="outline">{t("yearly")}</Badge>}
</Group>
<CountdownValue state={state} now={now} locale={locale} showSeconds={showSeconds} compact />
<Stack gap={4} mt="auto">
{showProgress && <ProgressBar state={state} now={now} label={title} />}
<Text size="xs" c="dimmed">
{formatOccurrence(state, locale)}
</Text>
</Stack>
</Stack>
</Paper>
))}
</SimpleGrid>
</Stack>
);
};
const AlertControls = ({
disabled,
persistent,
preferences,
notificationPermission,
onNotificationsChange,
}: {
disabled: boolean;
persistent: boolean;
preferences: AlertPreferences;
notificationPermission: ReturnType<typeof getBrowserNotificationPermission>;
onNotificationsChange: (enabled: boolean) => Promise<void>;
}) => {
const t = useScopedI18n("widget.countdown.alert");
if (!persistent) return null;
return (
<Paper withBorder p="sm" radius="md">
<Switch
checked={preferences.notifications && notificationPermission === "granted"}
disabled={disabled || notificationPermission === "unsupported" || notificationPermission === "denied"}
onChange={(event) => void onNotificationsChange(event.currentTarget.checked)}
label={t("notifications")}
thumbIcon={preferences.notifications ? <IconBell size={12} /> : <IconBellOff size={12} />}
/>
{notificationPermission === "denied" && (
<Text size="xs" c="dimmed" mt="xs">
{t("permissionDenied")}
</Text>
)}
</Paper>
);
};
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);
};