mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

dates.ts

Language TypeScript10.2 KB279 lines

packages/ninety-client/src/dates.ts

  1. /**
  2. * Timezone-correct dates.
  3. *
  4. * Ninety stores a to-do's due date as end of day in the user's local time,
  5. * serialized to UTC. A to-do due August 7 in Chicago comes over the wire as
  6. * "2026-08-08T04:59:59.999Z".
  7. *
  8. * So `dueDate.slice(0, 10)` (the obvious thing) yields "2026-08-08" and every
  9. * date on the dashboard renders one day late: the Ninety UI says Aug 7, a
  10. * sliced string says Aug 8.
  11. *
  12. * Three things break if this is not handled centrally:
  13. * 1. Every displayed date is off by one.
  14. * 2. "Due today" / "due this week" buckets land on the wrong day.
  15. * 3. The daily briefing diffs against a UTC midnight boundary rather than a
  16. * Central one, so "what changed since yesterday" is computed over the wrong
  17. * window.
  18. *
  19. * Note that overdue comparisons are not affected: the timestamp is a real
  20. * instant, so `Date.parse(dueDate) < Date.now()` is correct. Only calendar-day
  21. * arithmetic and display need the timezone. Keep that distinction: comparing
  22. * instants is right, formatting instants without a timezone is not.
  23. *
  24. * Uses Intl only, so it runs unchanged in a Cloudflare Worker.
  25. */
  26. /** Meridian operates on Central time. Override per-call if that ever stops being true. */
  27. export const MERIDIAN_TZ = 'America/Chicago'
  28. /** en-CA formats as YYYY-MM-DD, which is what we want and is not a coincidence. */
  29. const formatters = new Map<string, Intl.DateTimeFormat>()
  30. function dayFormatter(tz: string): Intl.DateTimeFormat {
  31. let f = formatters.get(tz)
  32. if (!f) {
  33. f = new Intl.DateTimeFormat('en-CA', {
  34. timeZone: tz,
  35. year: 'numeric',
  36. month: '2-digit',
  37. day: '2-digit',
  38. })
  39. formatters.set(tz, f)
  40. }
  41. return f
  42. }
  43. const stampFormatters = new Map<string, Intl.DateTimeFormat>()
  44. /**
  45. * An instant as a person in Meridian's timezone would read it: "Sep 4, 2:35 PM".
  46. *
  47. * The one clock-time formatter. Without it every surface that needs a clock
  48. * time hand-rolls its own Intl formatter, and the ones that do not bother
  49. * reach for `.toISOString().slice(0, 16)`, which renders UTC to a reader in
  50. * Chicago.
  51. *
  52. * The caller appends the zone label. Say "CT", not CDT or CST: the abbreviation
  53. * changes twice a year and nothing here should need editing when it does; the
  54. * offset is already handled by Intl.
  55. */
  56. export function localStamp(iso: string | number | Date, tz: string = MERIDIAN_TZ): string {
  57. const d = iso instanceof Date ? iso : new Date(iso)
  58. // Callers render whatever the board stored, and a stored value can be absent
  59. // or malformed. A dash is a visible absence; "Invalid Date" is a bug report
  60. // from the reader.
  61. if (Number.isNaN(d.getTime())) return '–'
  62. let f = stampFormatters.get(tz)
  63. if (!f) {
  64. f = new Intl.DateTimeFormat('en-US', {
  65. timeZone: tz,
  66. month: 'short',
  67. day: 'numeric',
  68. hour: 'numeric',
  69. minute: '2-digit',
  70. })
  71. stampFormatters.set(tz, f)
  72. }
  73. return f.format(d)
  74. }
  75. /** Just the clock part, same rules. "2:35 PM". */
  76. export function localClock(iso: string | number | Date, tz: string = MERIDIAN_TZ): string {
  77. const d = iso instanceof Date ? iso : new Date(iso)
  78. if (Number.isNaN(d.getTime())) return '–'
  79. return new Intl.DateTimeFormat('en-US', {
  80. timeZone: tz,
  81. hour: 'numeric',
  82. minute: '2-digit',
  83. }).format(d)
  84. }
  85. /**
  86. * The calendar day an instant falls on, in the given timezone. "YYYY-MM-DD".
  87. *
  88. * This is the function that should be used everywhere a date is displayed,
  89. * grouped, or bucketed. Never slice an ISO string.
  90. */
  91. export function localDay(iso: string | Date, tz: string = MERIDIAN_TZ): string {
  92. const d = typeof iso === 'string' ? new Date(iso) : iso
  93. if (Number.isNaN(d.getTime())) throw new RangeError(`Invalid date: ${String(iso)}`)
  94. return dayFormatter(tz).format(d)
  95. }
  96. /** Midnight UTC of a "YYYY-MM-DD", for day arithmetic that ignores time of day. */
  97. function dayToUtcMs(day: string): number {
  98. const [y, m, d] = day.split('-').map(Number)
  99. return Date.UTC(y!, m! - 1, d!)
  100. }
  101. /**
  102. * Whole calendar days from `from` to `to`, in local time.
  103. * Negative means `to` is in the past. Time of day is deliberately ignored: a
  104. * to-do due at 11:59 PM yesterday is one day overdue, not 0.4 days.
  105. */
  106. export function calendarDaysBetween(
  107. from: string | Date,
  108. to: string | Date,
  109. tz: string = MERIDIAN_TZ,
  110. ): number {
  111. return Math.round((dayToUtcMs(localDay(to, tz)) - dayToUtcMs(localDay(from, tz))) / 86_400_000)
  112. }
  113. /**
  114. * True once the due instant has passed. Instant comparison, not calendar. A
  115. * to-do due 11:59 PM tonight is not overdue at 9 AM this morning.
  116. */
  117. export function isOverdue(dueDate: string | null, now: Date = new Date()): boolean {
  118. if (!dueDate) return false
  119. const t = Date.parse(dueDate)
  120. return Number.isFinite(t) && t < now.getTime()
  121. }
  122. const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
  123. /** "Aug 7": matches how the Ninety UI writes it, which matters for comparison. */
  124. export function shortDate(iso: string | Date, tz: string = MERIDIAN_TZ): string {
  125. const [, m, d] = localDay(iso, tz).split('-')
  126. return `${MONTHS[Number(m) - 1]} ${Number(d)}`
  127. }
  128. /**
  129. * "Sep 14" from a bare calendar date somebody typed. Not for instants.
  130. *
  131. * Not `shortDate`, and the difference matters. `new Date('2026-09-14')` is UTC
  132. * midnight, which is 7 PM on the 13th in Chicago, so converting a bare date
  133. * renders it as the day before. A date with no time of day has nothing to
  134. * convert, so this reads the parts and formats them, and converts nothing.
  135. *
  136. * Lives here rather than in a surface because two surfaces now format bare
  137. * calendar dates (onboarding start dates and end-of-day note days), and the
  138. * subtlety above is worth having in one place.
  139. */
  140. export function calendarDate(day: string): string {
  141. const [, , m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []
  142. return m && d ? `${MONTHS[Number(m) - 1]} ${Number(d)}` : day
  143. }
  144. const MONTHS_LONG = [
  145. 'January', 'February', 'March', 'April', 'May', 'June',
  146. 'July', 'August', 'September', 'October', 'November', 'December',
  147. ]
  148. /**
  149. * "September 14, 2026" from a bare calendar date. The long form of
  150. * `calendarDate`, and the same rule applies for the same reason: it reads the
  151. * parts and converts nothing, because a date somebody typed has no time of day
  152. * to convert. See the note above: converting it prints the day before.
  153. *
  154. * Here rather than in the surface that wants it because that subtlety belongs
  155. * in one place, next to the function it is already written about.
  156. */
  157. export function calendarDateLong(day: string): string {
  158. const [, y, m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []
  159. return y && m && d ? `${MONTHS_LONG[Number(m) - 1]} ${Number(d)}, ${y}` : day
  160. }
  161. /**
  162. * A due date the way a person reads it: "Aug 7 (2 days overdue)".
  163. *
  164. * Models get this too. An ISO string alone makes them do date arithmetic, which
  165. * they do badly; a relative hint removes the need.
  166. */
  167. export function dueLabel(dueDate: string | null, now: Date = new Date(), tz: string = MERIDIAN_TZ): string {
  168. if (!dueDate) return 'no due date'
  169. const days = calendarDaysBetween(now, dueDate, tz)
  170. const rel =
  171. days === 0
  172. ? 'due today'
  173. : days === 1
  174. ? 'due tomorrow'
  175. : days === -1
  176. ? '1 day overdue'
  177. : days < 0
  178. ? `${-days} days overdue`
  179. : `in ${days} days`
  180. return `${shortDate(dueDate, tz)} (${rel})`
  181. }
  182. // --- review gates -----------------------------------------------------------
  183. /**
  184. * Meridian reviews to-dos on Mondays, occasionally Wednesdays. Nothing can be marked
  185. * complete outside a review, by policy.
  186. *
  187. * That makes raw "days overdue" the wrong unit. A to-do due Friday cannot be
  188. * closed until Monday, so on Saturday it is technically overdue and practically
  189. * on time. Counting review gates missed separates "waiting for the next review"
  190. * from "has survived reviews without being closed", and only the second is
  191. * actually late.
  192. *
  193. * 0 = Sunday. Default is Monday only; Wednesday is occasional and deliberately
  194. * not assumed, since assuming it would understate lateness.
  195. */
  196. export const DEFAULT_REVIEW_DAYS = [1]
  197. /**
  198. * How many review days have passed since a to-do came due.
  199. *
  200. * 0 means it is waiting for its first review and should not be reported as late.
  201. * 1 or more means it was reviewable and did not get closed.
  202. */
  203. export function reviewGatesMissed(
  204. dueDate: string | null,
  205. now: Date = new Date(),
  206. opts: { reviewDays?: number[]; tz?: string } = {},
  207. ): number {
  208. if (!dueDate) return 0
  209. const tz = opts.tz ?? MERIDIAN_TZ
  210. const days = opts.reviewDays ?? DEFAULT_REVIEW_DAYS
  211. const dueMs = dayToUtcMs(localDay(dueDate, tz))
  212. const nowMs = dayToUtcMs(localDay(now, tz))
  213. if (nowMs <= dueMs) return 0
  214. let count = 0
  215. // Walk calendar days strictly after the due date, through today inclusive.
  216. for (let ms = dueMs + 86_400_000; ms <= nowMs; ms += 86_400_000) {
  217. if (days.includes(new Date(ms).getUTCDay())) count++
  218. }
  219. return count
  220. }
  221. /**
  222. * "Actually late" for Meridian: overdue and has survived at least one review.
  223. *
  224. * This is the number that belongs on the dashboard and in the briefing. Raw
  225. * overdue counts inflate every weekend and reset every Monday, which trains
  226. * people to ignore them.
  227. */
  228. export function isActuallyLate(
  229. dueDate: string | null,
  230. now: Date = new Date(),
  231. opts: { reviewDays?: number[]; tz?: string } = {},
  232. ): boolean {
  233. return isOverdue(dueDate, now) && reviewGatesMissed(dueDate, now, opts) > 0
  234. }
  235. /** Start-of-day boundaries in local time, for "what changed since yesterday". */
  236. export function localDayBounds(day: string, tz: string = MERIDIAN_TZ): { startMs: number; endMs: number } {
  237. // Walk back from a UTC guess until the local day matches; at most a day either
  238. // way, so a small scan is cheaper and clearer than reimplementing tz offsets.
  239. const guess = dayToUtcMs(day)
  240. let startMs = guess
  241. for (let h = -14; h <= 14; h++) {
  242. const candidate = guess + h * 3_600_000
  243. if (localDay(new Date(candidate), tz) === day && localDay(new Date(candidate - 1), tz) !== day) {
  244. startMs = candidate
  245. break
  246. }
  247. }
  248. let endMs = startMs + 86_400_000
  249. for (let h = -2; h <= 2; h++) {
  250. const candidate = endMs + h * 3_600_000
  251. if (localDay(new Date(candidate - 1), tz) === day && localDay(new Date(candidate), tz) !== day) {
  252. endMs = candidate
  253. break
  254. }
  255. }
  256. return { startMs, endMs }
  257. }