feat: expand utility widgets (#6638)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-20 16:26:04 +02:00
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);
};
+64
View File
@@ -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(" ");
};
+24
View File
@@ -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;