Repository Public
1/** Client-safe date helpers. All formatting goes through Intl with an explicit IANA zone. */2export type Loc = "fr" | "en";3const tag = (l: Loc) => (l === "fr" ? "fr-CA" : "en-CA");45export const fmtTime = (iso: string | Date, tz: string, l: Loc) => new Intl.DateTimeFormat(tag(l), { timeZone: tz, hour: "numeric", minute: "2-digit" }).format(new Date(iso));6export const fmtDay = (iso: string | Date, tz: string, l: Loc, opts: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long", year: "numeric" }) => new Intl.DateTimeFormat(tag(l), { timeZone: tz, ...opts }).format(new Date(iso));7export const fmtDayShort = (iso: string | Date, tz: string, l: Loc) => fmtDay(iso, tz, l, { weekday: "short", day: "numeric", month: "short" });8export const fmtRange = (start: string | Date, end: string | Date, tz: string, l: Loc) => `${fmtTime(start, tz, l)} – ${fmtTime(end, tz, l)}`;9export const fmtDateTime = (iso: string | Date, tz: string, l: Loc) => `${fmtDayShort(iso, tz, l)}, ${fmtTime(iso, tz, l)}`;1011export function tzAbbr(iso: string | Date, tz: string, l: Loc): string {12 return new Intl.DateTimeFormat(tag(l), { timeZone: tz, timeZoneName: "short" }).formatToParts(new Date(iso)).find((p) => p.type === "timeZoneName")?.value ?? tz;13}14export const tzCity = (tz: string) => tz.split("/").pop()!.replaceAll("_", " ");1516/** YYYY-MM-DD of an instant in a zone. */17export function ymdInTz(iso: string | Date, tz: string): string {18 const p = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(new Date(iso));19 const g = (t: string) => p.find((x) => x.type === t)!.value;20 return `${g("year")}-${g("month")}-${g("day")}`;21}22/** Minutes since local midnight of an instant in a zone. */23export function minutesInTz(iso: string | Date, tz: string): number {24 const p = new Intl.DateTimeFormat("en-GB", { timeZone: tz, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(iso));25 return Number(p.find((x) => x.type === "hour")!.value) * 60 + Number(p.find((x) => x.type === "minute")!.value);26}2728/** Calendar-date arithmetic on YYYY-MM-DD strings (no time zone involved). */29export function addDays(ymd: string, n: number): string {30 const [y, m, d] = ymd.split("-").map(Number);31 return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);32}33export const weekdayOf = (ymd: string): number => { const [y, m, d] = ymd.split("-").map(Number); return ((new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7) + 1; }; // 1 = Monday34export const startOfWeek = (ymd: string) => addDays(ymd, 1 - weekdayOf(ymd));35export const startOfMonth = (ymd: string) => ymd.slice(0, 8) + "01";36export function addMonths(ymd: string, n: number): string { const [y, m] = ymd.split("-").map(Number); return new Date(Date.UTC(y, m - 1 + n, 1)).toISOString().slice(0, 10); }37/** A calendar date shown without any zone shift. */38export const fmtYmd = (ymd: string, l: Loc, opts: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tag(l), { timeZone: "UTC", ...opts }).format(new Date(ymd + "T12:00:00Z"));39export const minutesLabel = (min: number, l: Loc) => new Intl.DateTimeFormat(tag(l), { timeZone: "UTC", hour: "numeric", minute: "2-digit" }).format(new Date(Date.UTC(2020, 0, 1, Math.floor(min / 60) % 24, min % 60)));40export const hhmm = (min: number) => `${String(Math.floor(min / 60) % 24).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;41export const parseHhmm = (s: string) => { const [h, m] = s.split(":").map(Number); return h * 60 + m; };42