dates.ts
Language TypeScript10.2 KB279 lines
/*** Timezone-correct dates.** Ninety stores a to-do's due date as end of day in the user's local time,* serialized to UTC. A to-do due August 7 in Chicago comes over the wire as* "2026-08-08T04:59:59.999Z".** So `dueDate.slice(0, 10)` (the obvious thing) yields "2026-08-08" and every* date on the dashboard renders one day late: the Ninety UI says Aug 7, a* sliced string says Aug 8.** Three things break if this is not handled centrally:* 1. Every displayed date is off by one.* 2. "Due today" / "due this week" buckets land on the wrong day.* 3. The daily briefing diffs against a UTC midnight boundary rather than a* Central one, so "what changed since yesterday" is computed over the wrong* window.** Note that overdue comparisons are not affected: the timestamp is a real* instant, so `Date.parse(dueDate) < Date.now()` is correct. Only calendar-day* arithmetic and display need the timezone. Keep that distinction: comparing* instants is right, formatting instants without a timezone is not.** Uses Intl only, so it runs unchanged in a Cloudflare Worker.*//** Meridian operates on Central time. Override per-call if that ever stops being true. */export const MERIDIAN_TZ = 'America/Chicago'/** en-CA formats as YYYY-MM-DD, which is what we want and is not a coincidence. */const formatters = new Map<string, Intl.DateTimeFormat>()function dayFormatter(tz: string): Intl.DateTimeFormat {let f = formatters.get(tz)if (!f) {f = new Intl.DateTimeFormat('en-CA', {timeZone: tz,year: 'numeric',month: '2-digit',day: '2-digit',})formatters.set(tz, f)}return f}const stampFormatters = new Map<string, Intl.DateTimeFormat>()/*** An instant as a person in Meridian's timezone would read it: "Sep 4, 2:35 PM".** The one clock-time formatter. Without it every surface that needs a clock* time hand-rolls its own Intl formatter, and the ones that do not bother* reach for `.toISOString().slice(0, 16)`, which renders UTC to a reader in* Chicago.** The caller appends the zone label. Say "CT", not CDT or CST: the abbreviation* changes twice a year and nothing here should need editing when it does; the* offset is already handled by Intl.*/export function localStamp(iso: string | number | Date, tz: string = MERIDIAN_TZ): string {const d = iso instanceof Date ? iso : new Date(iso)// Callers render whatever the board stored, and a stored value can be absent// or malformed. A dash is a visible absence; "Invalid Date" is a bug report// from the reader.if (Number.isNaN(d.getTime())) return '–'let f = stampFormatters.get(tz)if (!f) {f = new Intl.DateTimeFormat('en-US', {timeZone: tz,month: 'short',day: 'numeric',hour: 'numeric',minute: '2-digit',})stampFormatters.set(tz, f)}return f.format(d)}/** Just the clock part, same rules. "2:35 PM". */export function localClock(iso: string | number | Date, tz: string = MERIDIAN_TZ): string {const d = iso instanceof Date ? iso : new Date(iso)if (Number.isNaN(d.getTime())) return '–'return new Intl.DateTimeFormat('en-US', {timeZone: tz,hour: 'numeric',minute: '2-digit',}).format(d)}/*** The calendar day an instant falls on, in the given timezone. "YYYY-MM-DD".** This is the function that should be used everywhere a date is displayed,* grouped, or bucketed. Never slice an ISO string.*/export function localDay(iso: string | Date, tz: string = MERIDIAN_TZ): string {const d = typeof iso === 'string' ? new Date(iso) : isoif (Number.isNaN(d.getTime())) throw new RangeError(`Invalid date: ${String(iso)}`)return dayFormatter(tz).format(d)}/** Midnight UTC of a "YYYY-MM-DD", for day arithmetic that ignores time of day. */function dayToUtcMs(day: string): number {const [y, m, d] = day.split('-').map(Number)return Date.UTC(y!, m! - 1, d!)}/*** Whole calendar days from `from` to `to`, in local time.* Negative means `to` is in the past. Time of day is deliberately ignored: a* to-do due at 11:59 PM yesterday is one day overdue, not 0.4 days.*/export function calendarDaysBetween(from: string | Date,to: string | Date,tz: string = MERIDIAN_TZ,): number {return Math.round((dayToUtcMs(localDay(to, tz)) - dayToUtcMs(localDay(from, tz))) / 86_400_000)}/*** True once the due instant has passed. Instant comparison, not calendar. A* to-do due 11:59 PM tonight is not overdue at 9 AM this morning.*/export function isOverdue(dueDate: string | null, now: Date = new Date()): boolean {if (!dueDate) return falseconst t = Date.parse(dueDate)return Number.isFinite(t) && t < now.getTime()}const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']/** "Aug 7": matches how the Ninety UI writes it, which matters for comparison. */export function shortDate(iso: string | Date, tz: string = MERIDIAN_TZ): string {const [, m, d] = localDay(iso, tz).split('-')return `${MONTHS[Number(m) - 1]} ${Number(d)}`}/*** "Sep 14" from a bare calendar date somebody typed. Not for instants.** Not `shortDate`, and the difference matters. `new Date('2026-09-14')` is UTC* midnight, which is 7 PM on the 13th in Chicago, so converting a bare date* renders it as the day before. A date with no time of day has nothing to* convert, so this reads the parts and formats them, and converts nothing.** Lives here rather than in a surface because two surfaces now format bare* calendar dates (onboarding start dates and end-of-day note days), and the* subtlety above is worth having in one place.*/export function calendarDate(day: string): string {const [, , m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []return m && d ? `${MONTHS[Number(m) - 1]} ${Number(d)}` : day}const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June','July', 'August', 'September', 'October', 'November', 'December',]/*** "September 14, 2026" from a bare calendar date. The long form of* `calendarDate`, and the same rule applies for the same reason: it reads the* parts and converts nothing, because a date somebody typed has no time of day* to convert. See the note above: converting it prints the day before.** Here rather than in the surface that wants it because that subtlety belongs* in one place, next to the function it is already written about.*/export function calendarDateLong(day: string): string {const [, y, m, d] = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day) ?? []return y && m && d ? `${MONTHS_LONG[Number(m) - 1]} ${Number(d)}, ${y}` : day}/*** A due date the way a person reads it: "Aug 7 (2 days overdue)".** Models get this too. An ISO string alone makes them do date arithmetic, which* they do badly; a relative hint removes the need.*/export function dueLabel(dueDate: string | null, now: Date = new Date(), tz: string = MERIDIAN_TZ): string {if (!dueDate) return 'no due date'const days = calendarDaysBetween(now, dueDate, tz)const rel =days === 0? 'due today': days === 1? 'due tomorrow': days === -1? '1 day overdue': days < 0? `${-days} days overdue`: `in ${days} days`return `${shortDate(dueDate, tz)} (${rel})`}// --- review gates -----------------------------------------------------------/*** Meridian reviews to-dos on Mondays, occasionally Wednesdays. Nothing can be marked* complete outside a review, by policy.** That makes raw "days overdue" the wrong unit. A to-do due Friday cannot be* closed until Monday, so on Saturday it is technically overdue and practically* on time. Counting review gates missed separates "waiting for the next review"* from "has survived reviews without being closed", and only the second is* actually late.** 0 = Sunday. Default is Monday only; Wednesday is occasional and deliberately* not assumed, since assuming it would understate lateness.*/export const DEFAULT_REVIEW_DAYS = [1]/*** How many review days have passed since a to-do came due.** 0 means it is waiting for its first review and should not be reported as late.* 1 or more means it was reviewable and did not get closed.*/export function reviewGatesMissed(dueDate: string | null,now: Date = new Date(),opts: { reviewDays?: number[]; tz?: string } = {},): number {if (!dueDate) return 0const tz = opts.tz ?? MERIDIAN_TZconst days = opts.reviewDays ?? DEFAULT_REVIEW_DAYSconst dueMs = dayToUtcMs(localDay(dueDate, tz))const nowMs = dayToUtcMs(localDay(now, tz))if (nowMs <= dueMs) return 0let count = 0// Walk calendar days strictly after the due date, through today inclusive.for (let ms = dueMs + 86_400_000; ms <= nowMs; ms += 86_400_000) {if (days.includes(new Date(ms).getUTCDay())) count++}return count}/*** "Actually late" for Meridian: overdue and has survived at least one review.** This is the number that belongs on the dashboard and in the briefing. Raw* overdue counts inflate every weekend and reset every Monday, which trains* people to ignore them.*/export function isActuallyLate(dueDate: string | null,now: Date = new Date(),opts: { reviewDays?: number[]; tz?: string } = {},): boolean {return isOverdue(dueDate, now) && reviewGatesMissed(dueDate, now, opts) > 0}/** Start-of-day boundaries in local time, for "what changed since yesterday". */export function localDayBounds(day: string, tz: string = MERIDIAN_TZ): { startMs: number; endMs: number } {// Walk back from a UTC guess until the local day matches; at most a day either// way, so a small scan is cheaper and clearer than reimplementing tz offsets.const guess = dayToUtcMs(day)let startMs = guessfor (let h = -14; h <= 14; h++) {const candidate = guess + h * 3_600_000if (localDay(new Date(candidate), tz) === day && localDay(new Date(candidate - 1), tz) !== day) {startMs = candidatebreak}}let endMs = startMs + 86_400_000for (let h = -2; h <= 2; h++) {const candidate = endMs + h * 3_600_000if (localDay(new Date(candidate - 1), tz) === day && localDay(new Date(candidate), tz) !== day) {endMs = candidatebreak}}return { startMs, endMs }}