metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

109 lines
3.6 KiB
TypeScript

/**
* Time-zone helpers built on Intl only (no dependencies), so the same code
* runs in Node, edge runtimes and the browser.
*/
import type { Weekday } from "./types";
const fmtCache = new Map<string, Intl.DateTimeFormat>();
function fmt(tz: string): Intl.DateTimeFormat {
let f = fmtCache.get(tz);
if (!f) {
f = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
weekday: "short",
});
fmtCache.set(tz, f);
}
return f;
}
const WD: Record<string, Weekday> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
export interface LocalParts {
date: string; // YYYY-MM-DD
time: string; // HH:MM
minutes: number; // minutes since local midnight
weekday: Weekday;
}
export function localParts(utcMs: number, tz: string): LocalParts {
const p: Record<string, string> = {};
for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value;
const hour = p.hour === "24" ? "00" : p.hour;
return {
date: `${p.year}-${p.month}-${p.day}`,
time: `${hour}:${p.minute}`,
minutes: Number(hour) * 60 + Number(p.minute),
weekday: WD[p.weekday],
};
}
/** Offset of `tz` from UTC at instant `utcMs`, in minutes (Toronto EDT = -240). */
export function tzOffsetMinutes(utcMs: number, tz: string): number {
const p: Record<string, string> = {};
for (const part of fmt(tz).formatToParts(new Date(utcMs))) p[part.type] = part.value;
const hour = p.hour === "24" ? 0 : Number(p.hour);
const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, hour, +p.minute, +p.second);
return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / 60000);
}
/**
* Convert a local wall-clock time in `tz` to a UTC instant.
* Non-existent times (spring-forward gap) resolve forward; ambiguous times
* (fall-back overlap) resolve to the first occurrence.
*/
export function zonedToUtc(date: string, time: string, tz: string): number {
const [y, m, d] = date.split("-").map(Number);
const [hh, mm] = time.split(":").map(Number);
const guess = Date.UTC(y, m - 1, d, hh, mm);
const off1 = tzOffsetMinutes(guess, tz);
let utc = guess - off1 * 60000;
const off2 = tzOffsetMinutes(utc, tz);
if (off2 !== off1) {
const alt = guess - off2 * 60000;
// Prefer the candidate that round-trips; otherwise (gap) take the later instant.
if (localParts(alt, tz).time === time) utc = Math.min(utc, alt);
else if (localParts(utc, tz).time !== time) utc = Math.max(utc, alt);
}
return utc;
}
export function toMinutes(hhmm: string): number {
const [h, m] = hhmm.split(":").map(Number);
return h * 60 + m;
}
export function fromMinutes(min: number): string {
const h = Math.floor(min / 60);
const m = min % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
export function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number);
const t = new Date(Date.UTC(y, m - 1, d + days));
return t.toISOString().slice(0, 10);
}
export function diffDays(a: string, b: string): number {
const [y1, m1, d1] = a.split("-").map(Number);
const [y2, m2, d2] = b.split("-").map(Number);
return Math.round((Date.UTC(y1, m1 - 1, d1) - Date.UTC(y2, m2 - 1, d2)) / 86400000);
}
export function weekdayOf(date: string): Weekday {
const [y, m, d] = date.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).getUTCDay() as Weekday;
}
export function iso(ms: number): string {
return new Date(ms).toISOString();
}