timezone.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * Wall-clock arithmetic in a named timezone, without a library.
  3. *
  4. * The server may run in UTC while the workshop lives in Europe/Oslo. Any
  5. * code that turns "08:00" into an instant, or asks which day an instant
  6. * falls on, has to say whose clock it means. These helpers take the IANA
  7. * zone explicitly so the answer is the same on every machine.
  8. */
  9. export interface ZonedParts {
  10. year: number
  11. month: number
  12. day: number
  13. hour: number
  14. minute: number
  15. /** 0 = Sunday, as Date#getDay. */
  16. weekday: number
  17. }
  18. const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
  19. const formatters = new Map<string, Intl.DateTimeFormat>()
  20. function formatter(timeZone: string): Intl.DateTimeFormat {
  21. let f = formatters.get(timeZone)
  22. if (!f) {
  23. f = new Intl.DateTimeFormat('en-US', {
  24. timeZone,
  25. hourCycle: 'h23',
  26. year: 'numeric',
  27. month: '2-digit',
  28. day: '2-digit',
  29. hour: '2-digit',
  30. minute: '2-digit',
  31. weekday: 'short',
  32. })
  33. formatters.set(timeZone, f)
  34. }
  35. return f
  36. }
  37. /** What the clock on the wall in `timeZone` shows at `date`. */
  38. export function zonedParts(date: Date, timeZone: string): ZonedParts {
  39. const parts: Record<string, string> = {}
  40. for (const p of formatter(timeZone).formatToParts(date)) parts[p.type] = p.value
  41. return {
  42. year: Number(parts.year),
  43. month: Number(parts.month),
  44. day: Number(parts.day),
  45. hour: Number(parts.hour) % 24,
  46. minute: Number(parts.minute),
  47. weekday: WEEKDAYS.indexOf(parts.weekday),
  48. }
  49. }
  50. /** Offset of `timeZone` from UTC at `date`, in minutes. */
  51. export function zoneOffsetMinutes(date: Date, timeZone: string): number {
  52. const p = zonedParts(date, timeZone)
  53. const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, 0, 0)
  54. const truncated = Math.floor(date.getTime() / 60_000) * 60_000
  55. return Math.round((asUtc - truncated) / 60_000)
  56. }
  57. /**
  58. * The instant at which the wall clock in `timeZone` reads the given date
  59. * and time. Two passes settle the offset across a DST change; a time that
  60. * does not exist on that day resolves to the moment after the gap.
  61. */
  62. export function zonedDate(
  63. year: number,
  64. month: number,
  65. day: number,
  66. hour: number,
  67. minute: number,
  68. timeZone: string
  69. ): Date {
  70. const guess = Date.UTC(year, month - 1, day, hour, minute, 0, 0)
  71. let offset = zoneOffsetMinutes(new Date(guess), timeZone)
  72. let result = guess - offset * 60_000
  73. const check = zoneOffsetMinutes(new Date(result), timeZone)
  74. if (check !== offset) {
  75. offset = check
  76. result = guess - offset * 60_000
  77. }
  78. return new Date(result)
  79. }
  80. /** Midnight at the start of the day `date` falls on in `timeZone`. */
  81. export function startOfZonedDay(date: Date, timeZone: string): Date {
  82. const p = zonedParts(date, timeZone)
  83. return zonedDate(p.year, p.month, p.day, 0, 0, timeZone)
  84. }
  85. /** The same wall-clock day, `days` later. */
  86. export function addZonedDays(date: Date, days: number, timeZone: string): Date {
  87. const p = zonedParts(date, timeZone)
  88. return zonedDate(p.year, p.month, p.day + days, 0, 0, timeZone)
  89. }
  90. /** "HH:mm" on the day `date` falls on in `timeZone`. */
  91. export function atZonedTime(date: Date, hhmm: string, timeZone: string): Date {
  92. const [h, m] = hhmm.split(':').map(Number)
  93. const p = zonedParts(date, timeZone)
  94. return zonedDate(
  95. p.year,
  96. p.month,
  97. p.day,
  98. Number.isFinite(h) ? h : 0,
  99. Number.isFinite(m) ? m : 0,
  100. timeZone
  101. )
  102. }
  103. /** YYYY-MM-DD of the day `date` falls on in `timeZone`. */
  104. export function zonedDayKey(date: Date, timeZone: string): string {
  105. const p = zonedParts(date, timeZone)
  106. return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`
  107. }
  108. export function isZonedWeekend(date: Date, timeZone: string): boolean {
  109. const w = zonedParts(date, timeZone).weekday
  110. return w === 0 || w === 6
  111. }
  112. /** A zone the runtime accepts, or the fallback when the setting is empty or misspelt. */
  113. export function safeTimeZone(value: string | null | undefined, fallback = 'UTC'): string {
  114. if (!value) return fallback
  115. try {
  116. new Intl.DateTimeFormat('en-US', { timeZone: value })
  117. return value
  118. } catch {
  119. return fallback
  120. }
  121. }
  122. /**
  123. * A stand-in Date whose *browser-local* wall clock reads what `date` reads in
  124. * `timeZone`. For widgets that work in local time and cannot be told
  125. * otherwise: the returned instant is not `date` and must never be stored or
  126. * sent anywhere. Seconds are dropped, because the pickers that need this stop
  127. * at the minute. Undo it with `fromZonedWallClock`.
  128. */
  129. export function toZonedWallClock(date: Date, timeZone: string): Date {
  130. const p = zonedParts(date, timeZone)
  131. return new Date(p.year, p.month - 1, p.day, p.hour, p.minute, 0, 0)
  132. }
  133. /** The instant a local stand-in stands for: its wall clock, read in `timeZone`. */
  134. export function fromZonedWallClock(local: Date, timeZone: string): Date {
  135. return zonedDate(
  136. local.getFullYear(),
  137. local.getMonth() + 1,
  138. local.getDate(),
  139. local.getHours(),
  140. local.getMinutes(),
  141. timeZone
  142. )
  143. }
  144. /**
  145. * The date and time a form field should show for a stored instant: the
  146. * workshop's wall clock, as `YYYY-MM-DD` and `HH:MM`.
  147. *
  148. * These exist because a date field and a time field are read straight back as
  149. * a wall clock by the server (`toSafeWorkshopDate`), so filling them from the
  150. * browser's clock silently moves whatever is being edited. An empty
  151. * `timeZone` means the workshop has never chosen one, and then the browser's
  152. * own is all there is.
  153. */
  154. export function zonedDateInput(date: Date, timeZone: string): string {
  155. if (!timeZone) {
  156. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
  157. }
  158. const p = zonedParts(date, timeZone)
  159. return `${p.year}-${pad(p.month)}-${pad(p.day)}`
  160. }
  161. /** The same, for a time field. */
  162. export function zonedTimeInput(date: Date, timeZone: string): string {
  163. if (!timeZone) return `${pad(date.getHours())}:${pad(date.getMinutes())}`
  164. const p = zonedParts(date, timeZone)
  165. return `${pad(p.hour)}:${pad(p.minute)}`
  166. }
  167. function pad(value: number): string {
  168. return String(value).padStart(2, '0')
  169. }