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,42 @@
|
||||
.root {
|
||||
container-type: size;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.compactDuration {
|
||||
font-size: var(--mantine-font-size-xl);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compactProgress {
|
||||
width: 100%;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@container (max-width: 10.625rem) {
|
||||
.compactStatus,
|
||||
.compactTarget {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 8.75rem) {
|
||||
.compactDuration {
|
||||
font-size: clamp(var(--mantine-font-size-sm), 15cqw, var(--mantine-font-size-md));
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-height: 6.5625rem) {
|
||||
.compactTarget {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"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);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CountdownEventState } from "./recurrence";
|
||||
|
||||
const secondMs = 1_000;
|
||||
const minuteMs = 60 * secondMs;
|
||||
const hourMs = 60 * minuteMs;
|
||||
const dayMs = 24 * hourMs;
|
||||
|
||||
export interface CountdownDurationPart {
|
||||
unit: Intl.NumberFormatOptions["unit"];
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const getCountdownDurationParts = (
|
||||
state: CountdownEventState,
|
||||
now: Date,
|
||||
showSeconds: boolean,
|
||||
): CountdownDurationPart[] => {
|
||||
const occurrence = state.nextOccurrence;
|
||||
if (!occurrence) return [];
|
||||
const remaining = Math.max(0, occurrence.getTime() - now.getTime());
|
||||
const days = Math.floor(remaining / dayMs);
|
||||
const hours = Math.floor((remaining % dayMs) / hourMs);
|
||||
const minutes = Math.floor((remaining % hourMs) / minuteMs);
|
||||
const seconds = Math.floor((remaining % minuteMs) / secondMs);
|
||||
|
||||
if (showSeconds) {
|
||||
const parts: CountdownDurationPart[] = [];
|
||||
if (days > 0) parts.push({ unit: "day", value: days });
|
||||
if (days > 0 || hours > 0) parts.push({ unit: "hour", value: hours });
|
||||
if (days > 0 || hours > 0 || minutes > 0) parts.push({ unit: "minute", value: minutes });
|
||||
parts.push({ unit: "second", value: seconds });
|
||||
return parts;
|
||||
}
|
||||
|
||||
if (days > 0)
|
||||
return compactParts([
|
||||
{ unit: "day", value: days },
|
||||
{ unit: "hour", value: hours },
|
||||
]);
|
||||
if (hours > 0)
|
||||
return compactParts([
|
||||
{ unit: "hour", value: hours },
|
||||
{ unit: "minute", value: minutes },
|
||||
]);
|
||||
if (minutes > 0) return [{ unit: "minute", value: minutes }];
|
||||
return [{ unit: "minute", value: 0 }];
|
||||
};
|
||||
|
||||
const compactParts = (parts: CountdownDurationPart[]) => {
|
||||
const nonZero = parts.filter(({ value }) => value > 0);
|
||||
return nonZero.length > 0 ? nonZero : parts.slice(0, 1);
|
||||
};
|
||||
|
||||
export const formatCountdownDuration = (
|
||||
state: CountdownEventState,
|
||||
now: Date,
|
||||
showSeconds: boolean,
|
||||
locale: string,
|
||||
unitDisplay: Intl.NumberFormatOptions["unitDisplay"] = "short",
|
||||
) => {
|
||||
return getCountdownDurationParts(state, now, showSeconds)
|
||||
.map(({ unit, value }) => new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay }).format(value))
|
||||
.join(" ");
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IconHourglass } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { getTimeZoneOptions } from "../clock/world-clock";
|
||||
|
||||
const maximumCountdownEvents = 20;
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("countdown", {
|
||||
icon: IconHourglass,
|
||||
supportsAdvancedFocus: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
events: factory.dateTimeEventList({
|
||||
defaultValue: [],
|
||||
maxValues: maximumCountdownEvents,
|
||||
timeZoneOptions: getTimeZoneOptions(),
|
||||
withDescription: true,
|
||||
}),
|
||||
showProgress: factory.switch({ defaultValue: true }),
|
||||
showSeconds: factory.switch({ defaultValue: false, withDescription: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { OptionDateTimeEvent } from "../options";
|
||||
import { getCountdownDurationParts } from "./display";
|
||||
import {
|
||||
getCountdownEventState,
|
||||
getCountdownProgress,
|
||||
resolveWallDateTimeString,
|
||||
groupCountdownStates,
|
||||
} from "./recurrence";
|
||||
|
||||
const event = (overrides: Partial<OptionDateTimeEvent> = {}): OptionDateTimeEvent => ({
|
||||
id: "event",
|
||||
label: "Event",
|
||||
targetUtc: "2026-07-15T13:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
recurrence: "none",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("countdown recurrence", () => {
|
||||
it("preserves a yearly event's wall-clock time across DST offsets", () => {
|
||||
const recurring = event({ recurrence: "yearly" });
|
||||
const winterState = getCountdownEventState(recurring, new Date("2027-01-01T00:00:00.000Z"));
|
||||
|
||||
expect(winterState.nextOccurrence?.toISOString()).toBe("2027-07-15T13:00:00.000Z");
|
||||
});
|
||||
|
||||
it("skips non-leap years for a February 29 recurrence", () => {
|
||||
const recurring = event({
|
||||
recurrence: "yearly",
|
||||
targetUtc: "2024-02-29T12:00:00.000Z",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const state = getCountdownEventState(recurring, new Date("2025-01-01T00:00:00.000Z"));
|
||||
|
||||
expect(state.nextOccurrence?.toISOString()).toBe("2028-02-29T12:00:00.000Z");
|
||||
expect(state.previousOccurrence?.toISOString()).toBe("2024-02-29T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("selects the first valid time after a DST gap", () => {
|
||||
const resolved = resolveWallDateTimeString("2026-03-08 02:30:45", "America/New_York");
|
||||
expect(resolved?.toISOString()).toBe("2026-03-08T07:00:00.000Z");
|
||||
});
|
||||
|
||||
it("selects the earlier occurrence when a wall-clock time repeats", () => {
|
||||
const resolved = resolveWallDateTimeString("2026-11-01 01:30:00", "America/New_York");
|
||||
expect(resolved?.toISOString()).toBe("2026-11-01T05:30:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countdown ordering and progress", () => {
|
||||
const now = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
it("preserves configured order within event groups", () => {
|
||||
const states = [
|
||||
getCountdownEventState(event({ id: "later", targetUtc: "2026-06-03T12:00:00.000Z" }), now),
|
||||
getCountdownEventState(event({ id: "older", targetUtc: "2026-05-01T12:00:00.000Z" }), now),
|
||||
getCountdownEventState(event({ id: "sooner", targetUtc: "2026-06-02T12:00:00.000Z" }), now),
|
||||
getCountdownEventState(event({ id: "newer", targetUtc: "2026-05-31T12:00:00.000Z" }), now),
|
||||
];
|
||||
|
||||
const grouped = groupCountdownStates(states);
|
||||
expect(grouped.upcoming.map(({ event: item }) => item.id)).toEqual(["later", "sooner"]);
|
||||
expect(grouped.completed.map(({ event: item }) => item.id)).toEqual(["older", "newer"]);
|
||||
});
|
||||
|
||||
it("clamps one-time progress and omits it without a start", () => {
|
||||
const withStart = getCountdownEventState(
|
||||
event({ targetUtc: "2026-06-02T12:00:00.000Z", startUtc: "2026-05-31T12:00:00.000Z" }),
|
||||
now,
|
||||
);
|
||||
const withoutStart = getCountdownEventState(event({ targetUtc: "2026-06-02T12:00:00.000Z" }), now);
|
||||
|
||||
expect(getCountdownProgress(withStart, now)).toBe(50);
|
||||
expect(getCountdownProgress(withStart, new Date("2026-06-04T12:00:00.000Z"))).toBe(100);
|
||||
expect(getCountdownProgress(withoutStart, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the previous and next occurrence for yearly progress", () => {
|
||||
const state = getCountdownEventState(
|
||||
event({ recurrence: "yearly", targetUtc: "2024-01-01T00:00:00.000Z", timeZone: "UTC" }),
|
||||
new Date("2026-07-02T12:00:00.000Z"),
|
||||
);
|
||||
const progress = getCountdownProgress(state, new Date("2026-07-02T12:00:00.000Z"));
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
if (progress === null) throw new Error("Expected yearly progress");
|
||||
expect(progress).toBeGreaterThan(49);
|
||||
expect(progress).toBeLessThan(51);
|
||||
});
|
||||
|
||||
it("keeps seconds visible at every duration when enabled", () => {
|
||||
const longState = getCountdownEventState(event({ targetUtc: "2026-06-03T13:00:00.000Z" }), now);
|
||||
const shortState = getCountdownEventState(event({ targetUtc: "2026-06-02T11:30:45.000Z" }), now);
|
||||
|
||||
expect(getCountdownDurationParts(longState, now, true).some(({ unit }) => unit === "second")).toBe(true);
|
||||
expect(getCountdownDurationParts(shortState, now, true).some(({ unit }) => unit === "second")).toBe(true);
|
||||
expect(getCountdownDurationParts(longState, now, false).some(({ unit }) => unit === "second")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import type { OptionDateTimeEvent } from "../options";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const minuteMs = 60_000;
|
||||
const dayMs = 86_400_000;
|
||||
const timeZoneSupportCache = new Map<string, boolean>();
|
||||
|
||||
export interface WallDateTime {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
|
||||
export interface CountdownEventState {
|
||||
event: OptionDateTimeEvent;
|
||||
nextOccurrence: Date | null;
|
||||
previousOccurrence: Date | null;
|
||||
completed: boolean;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
export const isCountdownTimeZoneSupported = (timeZone: string) => {
|
||||
const cached = timeZoneSupportCache.get(timeZone);
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en", { timeZone }).format(0);
|
||||
timeZoneSupportCache.set(timeZone, true);
|
||||
return true;
|
||||
} catch {
|
||||
timeZoneSupportCache.set(timeZone, false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const parseWallDateTime = (value: string): WallDateTime | null => {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value);
|
||||
if (!match) return null;
|
||||
const parts: WallDateTime = {
|
||||
year: Number(match[1]),
|
||||
month: Number(match[2]),
|
||||
day: Number(match[3]),
|
||||
hour: Number(match[4]),
|
||||
minute: Number(match[5]),
|
||||
second: Number(match[6] ?? 0),
|
||||
};
|
||||
const validationDate = new Date(
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second),
|
||||
);
|
||||
if (
|
||||
validationDate.getUTCFullYear() !== parts.year ||
|
||||
validationDate.getUTCMonth() !== parts.month - 1 ||
|
||||
validationDate.getUTCDate() !== parts.day ||
|
||||
validationDate.getUTCHours() !== parts.hour ||
|
||||
validationDate.getUTCMinutes() !== parts.minute ||
|
||||
validationDate.getUTCSeconds() !== parts.second
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
export const formatWallDateTime = (instant: string | Date, timeZone: string) => {
|
||||
if (!isCountdownTimeZoneSupported(timeZone)) return "";
|
||||
const value = dayjs(instant);
|
||||
if (!value.isValid()) return "";
|
||||
return value.tz(timeZone).format("YYYY-MM-DD HH:mm:ss");
|
||||
};
|
||||
|
||||
const wallDateTimeEquals = (value: WallDateTime, candidate: dayjs.Dayjs) =>
|
||||
candidate.year() === value.year &&
|
||||
candidate.month() + 1 === value.month &&
|
||||
candidate.date() === value.day &&
|
||||
candidate.hour() === value.hour &&
|
||||
candidate.minute() === value.minute &&
|
||||
candidate.second() === value.second;
|
||||
|
||||
const resolveExactWallDateTime = (value: WallDateTime, timeZone: string): Date[] => {
|
||||
const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute, value.second);
|
||||
const offsets = new Set<number>();
|
||||
for (const dayOffset of [-2, -1, 0, 1, 2]) {
|
||||
offsets.add(
|
||||
dayjs
|
||||
.utc(naiveUtc + dayOffset * dayMs)
|
||||
.tz(timeZone)
|
||||
.utcOffset(),
|
||||
);
|
||||
}
|
||||
|
||||
const matches: Date[] = [];
|
||||
for (const offset of offsets) {
|
||||
const candidate = new Date(naiveUtc - offset * minuteMs);
|
||||
if (wallDateTimeEquals(value, dayjs(candidate).tz(timeZone))) matches.push(candidate);
|
||||
}
|
||||
// oxlint-disable-next-line unicorn/no-array-sort -- Array.toSorted is not supported in all target browsers.
|
||||
return [...matches].sort((left, right) => left.getTime() - right.getTime());
|
||||
};
|
||||
|
||||
/** Resolves a local wall-clock value, preferring the earlier repeated time and the first valid time after a DST gap. */
|
||||
export const resolveWallDateTime = (value: WallDateTime, timeZone: string): Date | null => {
|
||||
if (!isCountdownTimeZoneSupported(timeZone)) return null;
|
||||
const exactMatches = resolveExactWallDateTime(value, timeZone);
|
||||
if (exactMatches[0]) return exactMatches[0];
|
||||
|
||||
const naiveUtc = Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute);
|
||||
for (let minuteOffset = 1; minuteOffset <= 180; minuteOffset += 1) {
|
||||
const candidateWallDate = new Date(naiveUtc + minuteOffset * minuteMs);
|
||||
const candidateWall: WallDateTime = {
|
||||
year: candidateWallDate.getUTCFullYear(),
|
||||
month: candidateWallDate.getUTCMonth() + 1,
|
||||
day: candidateWallDate.getUTCDate(),
|
||||
hour: candidateWallDate.getUTCHours(),
|
||||
minute: candidateWallDate.getUTCMinutes(),
|
||||
second: candidateWallDate.getUTCSeconds(),
|
||||
};
|
||||
const matches = resolveExactWallDateTime(candidateWall, timeZone);
|
||||
if (matches[0]) return matches[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveWallDateTimeString = (value: string, timeZone: string) => {
|
||||
const wallDateTime = parseWallDateTime(value);
|
||||
if (!wallDateTime) return null;
|
||||
return resolveWallDateTime(wallDateTime, timeZone);
|
||||
};
|
||||
|
||||
const isLeapYear = (year: number) => year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
|
||||
const occurrenceForYear = (target: WallDateTime, year: number, timeZone: string) => {
|
||||
if (target.month === 2 && target.day === 29 && !isLeapYear(year)) return null;
|
||||
return resolveWallDateTime({ ...target, year }, timeZone);
|
||||
};
|
||||
|
||||
const findYearlyOccurrence = (
|
||||
target: WallDateTime,
|
||||
timeZone: string,
|
||||
startYear: number,
|
||||
direction: 1 | -1,
|
||||
predicate: (date: Date) => boolean,
|
||||
) => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const occurrence = occurrenceForYear(target, startYear + index * direction, timeZone);
|
||||
if (occurrence && predicate(occurrence)) return occurrence;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCountdownEventState = (event: OptionDateTimeEvent, now: Date): CountdownEventState => {
|
||||
const target = dayjs(event.targetUtc);
|
||||
if (!target.isValid() || !isCountdownTimeZoneSupported(event.timeZone)) {
|
||||
return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true };
|
||||
}
|
||||
|
||||
if (event.recurrence === "none") {
|
||||
const targetDate = target.toDate();
|
||||
const completed = targetDate.getTime() <= now.getTime();
|
||||
return {
|
||||
event,
|
||||
nextOccurrence: completed ? null : targetDate,
|
||||
previousOccurrence: completed ? targetDate : null,
|
||||
completed,
|
||||
invalid: false,
|
||||
};
|
||||
}
|
||||
|
||||
const targetWall = parseWallDateTime(formatWallDateTime(event.targetUtc, event.timeZone));
|
||||
if (!targetWall) {
|
||||
return { event, nextOccurrence: null, previousOccurrence: null, completed: false, invalid: true };
|
||||
}
|
||||
const currentYear = dayjs(now).tz(event.timeZone).year();
|
||||
const nextOccurrence = findYearlyOccurrence(
|
||||
targetWall,
|
||||
event.timeZone,
|
||||
currentYear,
|
||||
1,
|
||||
(occurrence) => occurrence.getTime() > now.getTime(),
|
||||
);
|
||||
const previousOccurrence = findYearlyOccurrence(
|
||||
targetWall,
|
||||
event.timeZone,
|
||||
currentYear,
|
||||
-1,
|
||||
(occurrence) => occurrence.getTime() <= now.getTime(),
|
||||
);
|
||||
return {
|
||||
event,
|
||||
nextOccurrence,
|
||||
previousOccurrence,
|
||||
completed: false,
|
||||
invalid: nextOccurrence === null,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCountdownProgress = (state: CountdownEventState, now: Date): number | null => {
|
||||
let start: Date | null = null;
|
||||
let end: Date | null = null;
|
||||
|
||||
if (state.event.recurrence === "yearly") {
|
||||
start = state.previousOccurrence;
|
||||
end = state.nextOccurrence;
|
||||
} else if (state.event.startUtc) {
|
||||
const parsedStart = dayjs(state.event.startUtc);
|
||||
const parsedEnd = dayjs(state.event.targetUtc);
|
||||
if (parsedStart.isValid() && parsedEnd.isValid()) {
|
||||
start = parsedStart.toDate();
|
||||
end = parsedEnd.toDate();
|
||||
}
|
||||
}
|
||||
|
||||
if (!start || !end || end.getTime() <= start.getTime()) return null;
|
||||
const progress = ((now.getTime() - start.getTime()) / (end.getTime() - start.getTime())) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
};
|
||||
|
||||
export const groupCountdownStates = (states: CountdownEventState[]) => {
|
||||
const upcoming = states.filter((state) => state.nextOccurrence !== null && !state.invalid);
|
||||
const completed = states.filter((state) => state.completed && state.previousOccurrence !== null && !state.invalid);
|
||||
const invalid = states.filter((state) => state.invalid);
|
||||
return { upcoming, completed, invalid };
|
||||
};
|
||||
|
||||
export const getLatestReachedOccurrence = (event: OptionDateTimeEvent, now: Date) =>
|
||||
getCountdownEventState(event, now).previousOccurrence;
|
||||
Reference in new issue
Block a user