Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
/**
|
|
* Local time in the application timezone.
|
|
*
|
|
* The admin-saved zone (settings `timezone`, an IANA name) is the single
|
|
* source of local-time truth for the whole application; the API publishes it
|
|
* as `effective_timezone`, naming its host's own zone when none is stored.
|
|
* "" means the API published no zone, and only then does the browser's own
|
|
* zone apply. Every helper takes
|
|
* that stored value as-is; an invalid zone name throws (Intl's RangeError).
|
|
*/
|
|
|
|
/** The `timeZone` option for Intl: the zone, or undefined (browser) for "". */
|
|
export function zoneOption(timeZone: string): string | undefined {
|
|
return timeZone === "" ? undefined : timeZone;
|
|
}
|
|
|
|
interface WallClock {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
hour: number;
|
|
minute: number;
|
|
second: number;
|
|
}
|
|
|
|
function wallClock(instant: number, timeZone: string): WallClock {
|
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
timeZone: zoneOption(timeZone),
|
|
hourCycle: "h23",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
}).formatToParts(instant);
|
|
const part = (type: Intl.DateTimeFormatPartTypes) =>
|
|
Number(parts.find((p) => p.type === type)?.value);
|
|
return {
|
|
year: part("year"),
|
|
month: part("month"),
|
|
day: part("day"),
|
|
hour: part("hour"),
|
|
minute: part("minute"),
|
|
second: part("second"),
|
|
};
|
|
}
|
|
|
|
/** The "YYYY-MM-DD" calendar date of `instant` in the zone. */
|
|
export function isoDateIn(instant: Date, timeZone: string): string {
|
|
const { year, month, day } = wallClock(instant.getTime(), timeZone);
|
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
}
|
|
|
|
/** Today's calendar date in the zone. */
|
|
export function todayIn(timeZone: string, now: Date = new Date()): string {
|
|
return isoDateIn(now, timeZone);
|
|
}
|
|
|
|
/** The zone's UTC offset in ms at `instant` (wall clock minus UTC). */
|
|
function offsetAt(instant: number, timeZone: string): number {
|
|
const c = wallClock(instant, timeZone);
|
|
const wholeSeconds = Math.floor(instant / 1000) * 1000;
|
|
return Date.UTC(c.year, c.month - 1, c.day, c.hour, c.minute, c.second) - wholeSeconds;
|
|
}
|
|
|
|
/**
|
|
* Epoch ms at which the zone's wall clock reads `hour:minute` on the
|
|
* "YYYY-MM-DD" date. Two passes settle the offset across a DST change.
|
|
*/
|
|
export function wallClockInstant(
|
|
iso: string,
|
|
hour: number,
|
|
minute: number,
|
|
timeZone: string,
|
|
): number {
|
|
const [year, month, day] = iso.split("-").map(Number);
|
|
const asUtc = Date.UTC(year, month - 1, day, hour, minute);
|
|
const guess = asUtc - offsetAt(asUtc, timeZone);
|
|
return asUtc - offsetAt(guess, timeZone);
|
|
}
|