Files
2026-08-07 11:38:39 +01:00

218 lines
5.8 KiB
TypeScript

export const AUTO_TZ = "auto";
export const DEFAULT_TZ = "UTC";
export function resolveTz(tz?: string | null): string {
if (tz && tz !== AUTO_TZ) return tz;
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TZ;
} catch {
return DEFAULT_TZ;
}
}
export function dateStrInTz(date: Date, tz: string): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const get = (type: string) =>
parts.find((p) => p.type === type)?.value ?? "00";
return `${get("year")}-${get("month")}-${get("day")}`;
}
export function weekdayInTz(date: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
}).formatToParts(date);
const wd = parts.find((p) => p.type === "weekday")?.value ?? "";
const map: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
return map[wd] ?? date.getDay();
}
export function todayInTz(tz: string): string {
return dateStrInTz(new Date(), tz);
}
export function addDaysStr(dateStr: string, days: number): string {
const [y, m, d] = dateStr.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
return dt.toISOString().slice(0, 10);
}
export function weekStart(payday: number, tz: string): string {
const today = todayInTz(tz);
const wd = weekdayInTz(new Date(), tz);
const back = (((wd - payday) % 7) + 7) % 7;
return addDaysStr(today, -back);
}
// The first configured-payday day strictly after dateStr (used to gate
// payday-settled bonus rewards). Weekly: periodEnd (weekStart+6) → next payday.
export function nextPaydayAfter(
dateStr: string,
payday: number,
tz: string,
): string {
let d = addDaysStr(dateStr, 1);
while (weekdayInTz(new Date(d + "T12:00:00Z"), tz) !== payday) {
d = addDaysStr(d, 1);
}
return d;
}
function monthStartStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
}
function monthEndStr(month?: string): string {
if (!month) {
const d = new Date();
month = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
const [y, m] = month.split("-").map(Number);
const lastDay = new Date(y, m, 0).getDate();
return `${month}-${String(lastDay).padStart(2, "0")}`;
}
export function periodStart(
period: string,
payday: number,
tz: string,
): string {
if (period === "daily") return todayInTz(tz);
if (period === "weekly") return weekStart(payday, tz);
if (period === "monthly") return monthStartStr();
return weekStart(payday, tz);
}
export function periodEnd(period: string, start: string): string {
if (period === "daily") return start;
if (period === "weekly") return addDaysStr(start, 6);
if (period === "monthly") return monthEndStr(start.slice(0, 7));
return start;
}
// The completion window a chore of a given frequency is "done for" in the
// current period. Weekly chores are done once per week ([weekStart, +7)),
// daily chores once per day ([today, +1)). Half-open [from, to).
export function periodWindow(
frequency: string,
payday: number,
tz: string,
): { from: string; to: string } {
if (frequency === "weekly") {
const from = weekStart(payday, tz);
return { from, to: addDaysStr(from, 7) };
}
const from = todayInTz(tz);
return { from, to: addDaysStr(from, 1) };
}
// True if any completion date falls within the current period window for the
// chore's frequency. Dates may be YYYY-MM-DD or ISO (time portion ignored).
export function isCompleteForPeriod(
frequency: string,
payday: number,
tz: string,
dates: string[],
): boolean {
const { from, to } = periodWindow(frequency, payday, tz);
return dates.some((d) => {
const day = (d || "").slice(0, 10);
return day >= from && day < to;
});
}
export function wallClockToUtc(
dateStr: string,
time: string,
tz: string,
): number {
const [y, m, d] = dateStr.split("-").map(Number);
const [h, min] = time.split(":").map(Number);
let epoch = Date.UTC(y, m - 1, d, h, min);
for (let i = 0; i < 4; i++) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date(epoch));
const get = (type: string) =>
parts.find((p) => p.type === type)?.value ?? "00";
const gotDate = `${get("year")}-${get("month")}-${get("day")}`;
let gotHour = parseInt(get("hour"), 10);
if (gotHour === 24) gotHour = 0;
const gotEpoch = Date.UTC(
parseInt(get("year"), 10),
parseInt(get("month"), 10) - 1,
parseInt(get("day"), 10),
gotHour,
parseInt(get("minute"), 10),
);
if (gotEpoch === epoch && gotDate === dateStr) break;
epoch = Date.UTC(y, m - 1, d, h, min) + (epoch - gotEpoch);
}
return epoch;
}
export const COMMON_TIMEZONES: string[] = [
"Europe/London",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Amsterdam",
"Europe/Lisbon",
"Europe/Dublin",
"Europe/Warsaw",
"Europe/Stockholm",
"Europe/Athens",
"Europe/Istanbul",
"Europe/Moscow",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Toronto",
"America/Vancouver",
"America/Sao_Paulo",
"America/Mexico_City",
"Asia/Tokyo",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Singapore",
"Asia/Seoul",
"Asia/Kolkata",
"Asia/Dubai",
"Asia/Jerusalem",
"Asia/Bangkok",
"Asia/Manila",
"Australia/Sydney",
"Australia/Melbourne",
"Australia/Brisbane",
"Australia/Perth",
"Australia/Adelaide",
"Pacific/Auckland",
"Africa/Johannesburg",
"Africa/Cairo",
"Africa/Lagos",
"Africa/Nairobi",
"UTC",
];