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

227 lines
7.5 KiB
TypeScript

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;
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) => {
try {
new Intl.DateTimeFormat("en", { timeZone }).format(0);
return true;
} catch {
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);
}
return matches.toSorted((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;