| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- /**
- * Wall-clock arithmetic in a named timezone, without a library.
- *
- * The server may run in UTC while the workshop lives in Europe/Oslo. Any
- * code that turns "08:00" into an instant, or asks which day an instant
- * falls on, has to say whose clock it means. These helpers take the IANA
- * zone explicitly so the answer is the same on every machine.
- */
- export interface ZonedParts {
- year: number
- month: number
- day: number
- hour: number
- minute: number
- /** 0 = Sunday, as Date#getDay. */
- weekday: number
- }
- const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
- const formatters = new Map<string, Intl.DateTimeFormat>()
- function formatter(timeZone: string): Intl.DateTimeFormat {
- let f = formatters.get(timeZone)
- if (!f) {
- f = new Intl.DateTimeFormat('en-US', {
- timeZone,
- hourCycle: 'h23',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- weekday: 'short',
- })
- formatters.set(timeZone, f)
- }
- return f
- }
- /** What the clock on the wall in `timeZone` shows at `date`. */
- export function zonedParts(date: Date, timeZone: string): ZonedParts {
- const parts: Record<string, string> = {}
- for (const p of formatter(timeZone).formatToParts(date)) parts[p.type] = p.value
- return {
- year: Number(parts.year),
- month: Number(parts.month),
- day: Number(parts.day),
- hour: Number(parts.hour) % 24,
- minute: Number(parts.minute),
- weekday: WEEKDAYS.indexOf(parts.weekday),
- }
- }
- /** Offset of `timeZone` from UTC at `date`, in minutes. */
- export function zoneOffsetMinutes(date: Date, timeZone: string): number {
- const p = zonedParts(date, timeZone)
- const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, 0, 0)
- const truncated = Math.floor(date.getTime() / 60_000) * 60_000
- return Math.round((asUtc - truncated) / 60_000)
- }
- /**
- * The instant at which the wall clock in `timeZone` reads the given date
- * and time. Two passes settle the offset across a DST change; a time that
- * does not exist on that day resolves to the moment after the gap.
- */
- export function zonedDate(
- year: number,
- month: number,
- day: number,
- hour: number,
- minute: number,
- timeZone: string
- ): Date {
- const guess = Date.UTC(year, month - 1, day, hour, minute, 0, 0)
- let offset = zoneOffsetMinutes(new Date(guess), timeZone)
- let result = guess - offset * 60_000
- const check = zoneOffsetMinutes(new Date(result), timeZone)
- if (check !== offset) {
- offset = check
- result = guess - offset * 60_000
- }
- return new Date(result)
- }
- /** Midnight at the start of the day `date` falls on in `timeZone`. */
- export function startOfZonedDay(date: Date, timeZone: string): Date {
- const p = zonedParts(date, timeZone)
- return zonedDate(p.year, p.month, p.day, 0, 0, timeZone)
- }
- /** The same wall-clock day, `days` later. */
- export function addZonedDays(date: Date, days: number, timeZone: string): Date {
- const p = zonedParts(date, timeZone)
- return zonedDate(p.year, p.month, p.day + days, 0, 0, timeZone)
- }
- /** "HH:mm" on the day `date` falls on in `timeZone`. */
- export function atZonedTime(date: Date, hhmm: string, timeZone: string): Date {
- const [h, m] = hhmm.split(':').map(Number)
- const p = zonedParts(date, timeZone)
- return zonedDate(
- p.year,
- p.month,
- p.day,
- Number.isFinite(h) ? h : 0,
- Number.isFinite(m) ? m : 0,
- timeZone
- )
- }
- /** YYYY-MM-DD of the day `date` falls on in `timeZone`. */
- export function zonedDayKey(date: Date, timeZone: string): string {
- const p = zonedParts(date, timeZone)
- return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`
- }
- export function isZonedWeekend(date: Date, timeZone: string): boolean {
- const w = zonedParts(date, timeZone).weekday
- return w === 0 || w === 6
- }
- /** A zone the runtime accepts, or the fallback when the setting is empty or misspelt. */
- export function safeTimeZone(value: string | null | undefined, fallback = 'UTC'): string {
- if (!value) return fallback
- try {
- new Intl.DateTimeFormat('en-US', { timeZone: value })
- return value
- } catch {
- return fallback
- }
- }
- /**
- * A stand-in Date whose *browser-local* wall clock reads what `date` reads in
- * `timeZone`. For widgets that work in local time and cannot be told
- * otherwise: the returned instant is not `date` and must never be stored or
- * sent anywhere. Seconds are dropped, because the pickers that need this stop
- * at the minute. Undo it with `fromZonedWallClock`.
- */
- export function toZonedWallClock(date: Date, timeZone: string): Date {
- const p = zonedParts(date, timeZone)
- return new Date(p.year, p.month - 1, p.day, p.hour, p.minute, 0, 0)
- }
- /** The instant a local stand-in stands for: its wall clock, read in `timeZone`. */
- export function fromZonedWallClock(local: Date, timeZone: string): Date {
- return zonedDate(
- local.getFullYear(),
- local.getMonth() + 1,
- local.getDate(),
- local.getHours(),
- local.getMinutes(),
- timeZone
- )
- }
- /**
- * The date and time a form field should show for a stored instant: the
- * workshop's wall clock, as `YYYY-MM-DD` and `HH:MM`.
- *
- * These exist because a date field and a time field are read straight back as
- * a wall clock by the server (`toSafeWorkshopDate`), so filling them from the
- * browser's clock silently moves whatever is being edited. An empty
- * `timeZone` means the workshop has never chosen one, and then the browser's
- * own is all there is.
- */
- export function zonedDateInput(date: Date, timeZone: string): string {
- if (!timeZone) {
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
- }
- const p = zonedParts(date, timeZone)
- return `${p.year}-${pad(p.month)}-${pad(p.day)}`
- }
- /** The same, for a time field. */
- export function zonedTimeInput(date: Date, timeZone: string): string {
- if (!timeZone) return `${pad(date.getHours())}:${pad(date.getMinutes())}`
- const p = zonedParts(date, timeZone)
- return `${pad(p.hour)}:${pad(p.minute)}`
- }
- function pad(value: number): string {
- return String(value).padStart(2, '0')
- }
|