/** * Civil dates, in the reader's time zone rather than the server's. * * Almost every date in this product is a CIVIL one — the day you applied, the * day you said you would chase, "today", "overdue". None of those are instants; * they are somebody's calendar. And every one of them used to be computed from * `new Date()` on the server, which on a hosted instance means UTC. A person in * Los Angeles saw a follow-up turn red at 5pm the day before it was due, was * greeted with "Good evening" over lunch, and had the app's 9am nudge land at * 1am. Nothing was broken enough to report; it was wrong every single day. * * So the profile carries an IANA zone and everything that decides a civil date * takes it. An empty zone means "the server's", which is what every account had * before this and is exactly right for a self-hoster running it on the machine * under their desk. * * No date library. `Intl` already ships the full IANA database in Node and in * every browser, and the two things this file needs — what the clock says in a * zone, and which instant a given local time is — are both derivable from it. */ /** Nothing set. Every function here treats it as "use the host's zone". */ export const SERVER_ZONE = ""; const partsOf = (date: Date, timeZone: string) => { const formatter = new Intl.DateTimeFormat("en-US", { ...(timeZone ? { timeZone } : {}), hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); const found = Object.fromEntries( formatter.formatToParts(date).map((part) => [part.type, part.value]), ); return { year: Number(found.year), month: Number(found.month), day: Number(found.day), // `hour12: false` reports midnight as 24 in some ICU builds, which would // otherwise push every midnight into the next day. hour: Number(found.hour) % 24, minute: Number(found.minute), second: Number(found.second), }; }; /** * What the wall clock said in `timeZone` at this instant. * * The whole file is built on this: format the instant in the zone and read the * numbers back. Whatever the zone's offset and DST rules are, Intl has already * applied them. */ export function clockIn(date: Date, timeZone: string) { return partsOf(date, timeZone); } /** * Which day of the week this instant falls on there, Monday 1 through Sunday 7. * * ISO numbering rather than JavaScript's Sunday-zero, because every other * weekday in this app — startOfWeek, the calendar grid — is Monday-first, and * two conventions in one codebase is one off-by-one waiting to happen. */ export function weekdayIn(date: Date, timeZone: string): number { const name = new Intl.DateTimeFormat("en-US", { ...(timeZone ? { timeZone } : {}), weekday: "short", }).format(date); const order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const found = order.indexOf(name); // 0, not 1. This gates the weekly mail, and 1 is Monday — the single value // that sends. An unrecognised name should fail closed. return found === -1 ? 0 : found + 1; } /** "2026-03-14" — the calendar day this instant falls on, in that zone. */ export function civilDay(date: Date, timeZone: string): string { const { year, month, day } = partsOf(date, timeZone); const pad = (n: number) => String(n).padStart(2, "0"); return `${year}-${pad(month)}-${pad(day)}`; } /** * The instant at which the clock in `timeZone` reads this local time. * * Guess that the local time is UTC, measure how wrong that is at the guess, and * correct — twice, because the offset may itself differ at the corrected * instant, which is exactly what happens across a DST boundary. Two passes * converge everywhere except inside the spring-forward gap, where the local * time being asked for does not exist and any answer is a choice; this one * lands on the instant just after the jump. */ export function instantAt( timeZone: string, year: number, month: number, day: number, hour = 0, minute = 0, second = 0, ms = 0, ): Date { const wanted = Date.UTC(year, month - 1, day, hour, minute, second, ms); let guess = wanted; for (let pass = 0; pass < 2; pass++) { const at = partsOf(new Date(guess), timeZone); const shown = Date.UTC(at.year, at.month - 1, at.day, at.hour, at.minute, at.second, ms); guess = guess + (wanted - shown); } return new Date(guess); } /** * A bare "2026-03-14", split — or null for anything else. * * The distinction this whole file turns on: a string with no time in it is a * CIVIL date, somebody's calendar square, and it has no instant until you say * whose calendar. A string with a time in it already is an instant. */ export function parseCivilDay( value: string, ): { year: number; month: number; day: number } | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); if (!match) return null; return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; } /** * "2026-03-14" as the instant it starts — or reaches `hour` — where the reader * is. Null for anything that is not a bare civil date, so a caller can fall * through to reading it as an instant. */ export function civilInstant( timeZone: string, value: string, hour = 0, minute = 0, second = 0, ms = 0, ): Date | null { const civil = parseCivilDay(value); if (!civil) return null; return instantAt(timeZone, civil.year, civil.month, civil.day, hour, minute, second, ms); } /** Midnight this morning, where the reader is. */ export function startOfDay(timeZone: string, now = new Date()): Date { const { year, month, day } = partsOf(now, timeZone); return instantAt(timeZone, year, month, day, 0, 0, 0, 0); } /** The last millisecond of a day `daysAhead` from now, where the reader is. */ export function endOfDay(timeZone: string, daysAhead = 0, now = new Date()): Date { const start = startOfDay(timeZone, now); // Step by whole days on the CIVIL calendar rather than by 86,400,000ms: the // day a clock goes forward is 23 hours long, and adding a fixed day to it // lands an hour into the day after. const stepped = partsOf(new Date(start.getTime() + daysAhead * 86_400_000 + 3_600_000), timeZone); return instantAt(timeZone, stepped.year, stepped.month, stepped.day, 23, 59, 59, 999); } /** Monday morning of the reader's current week. */ export function startOfWeek(timeZone: string, now = new Date()): Date { const start = startOfDay(timeZone, now); // Which weekday it is where they are, not where the server is. const weekday = new Intl.DateTimeFormat("en-US", { ...(timeZone ? { timeZone } : {}), weekday: "short", }).format(now); const index = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(weekday); const back = index < 0 ? 0 : index; const stepped = partsOf(new Date(start.getTime() - back * 86_400_000 + 3_600_000), timeZone); return instantAt(timeZone, stepped.year, stepped.month, stepped.day, 0, 0, 0, 0); } /** * `daysAhead` days from now at `hour` o'clock, where the reader is. * * This is what schedules a follow-up: "in a week, at nine in the morning" has * to mean nine in THEIR morning or the reminder is useless. */ export function atHourInDays( timeZone: string, daysAhead: number, hour: number, now = new Date(), ): Date { const start = startOfDay(timeZone, now); const stepped = partsOf(new Date(start.getTime() + daysAhead * 86_400_000 + 3_600_000), timeZone); return instantAt(timeZone, stepped.year, stepped.month, stepped.day, hour, 0, 0, 0); } /** How many calendar days apart two instants are, on the reader's calendar. */ /** * `monthsAhead` calendar months from an instant, at `hour`, where the reader is. * * CLAMPS rather than rolling over: the 31st plus one month is the 28th of * February, not the 3rd of March. Rolling over is how a monthly task set on the * 31st drifts forward a day every short month until it is a different date * entirely. * * Built the way everything else here is — read the parts, build the instant back * — rather than by adding milliseconds, because a month is not a fixed number of * them and neither is a day across a DST boundary. */ export function atHourInMonths( timeZone: string, monthsAhead: number, hour: number, from: Date, ): Date { const parts = partsOf(from, timeZone); const total = parts.year * 12 + (parts.month - 1) + monthsAhead; const year = Math.floor(total / 12); const month = (total % 12) + 1; // The last day of the target month, so the 31st lands on the 28th rather than // rolling into the next one. const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); return instantAt(timeZone, year, month, Math.min(parts.day, lastDay), hour, 0, 0, 0); } export function daysBetween(from: Date, to: Date, timeZone: string): number { const a = partsOf(from, timeZone); const b = partsOf(to, timeZone); return Math.round( (Date.UTC(b.year, b.month - 1, b.day) - Date.UTC(a.year, a.month - 1, a.day)) / 86_400_000, ); } /** * An instant, written out on the reader's calendar and clock. * * Every date a person reads goes through here rather than through * `toLocaleDateString`. That method with no locale and no zone takes the HOST's * — which is Node on the server and the reader's machine in the browser — so a * component that renders on both produces two different strings for one render * and React throws the server's markup away. Even with a locale it still uses * the host's clock, which is how a hosted instance shows everyone UTC's day. * * The locale is pinned to en-US deliberately: the rest of this product's copy * is written in one language, and a date that changes shape depending on the * machine is a date two screens can disagree about. */ export function formatIn( date: Date, timeZone: string, options: Intl.DateTimeFormatOptions, ): string { return new Intl.DateTimeFormat("en-US", { ...(timeZone ? { timeZone } : {}), ...options, }).format(date); } /** "Sep 8" — a stored instant, on the reader's calendar. */ export function shortDay(date: Date, timeZone: string): string { return formatIn(date, timeZone, { month: "short", day: "numeric" }); } /** * A bare "2026-09-08", written out with no zone in the question at all. * * A civil date is already somebody's calendar square; it does not need one, and * reading it through a zone is how it drifts by a day. Formatted as UTC because * that is the only way to render a date built as UTC without moving it. */ export function formatCivilDay(value: string, options: Intl.DateTimeFormatOptions): string { const civil = parseCivilDay(value); if (!civil) return ""; return formatIn(new Date(Date.UTC(civil.year, civil.month - 1, civil.day)), "UTC", options); } /** "Sep 8", from a bare "2026-09-08". */ export function shortCivilDay(value: string): string { return formatCivilDay(value, { month: "short", day: "numeric" }); } /** * The zone this process is actually running in, by name. * * `""` is a fine stored value — it means "whatever this host's clock says" — * but it is NOT safe to hand to a component that renders on both sides of a * hydration: on the server it resolves to the machine's zone and in the browser * to the reader's, so the same call produces two different days and React * throws the text away. Resolve it to a name before it crosses that boundary. */ export function hostZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } /** * Whether a string is a zone this runtime actually knows. * * Anything reaching this came from a browser or from an assistant, and an * unknown zone would make every Intl call below throw at render time rather * than at the point it was set. */ export function isValidTimeZone(value: string): boolean { if (!value) return true; try { new Intl.DateTimeFormat("en-US", { timeZone: value }); return true; } catch { return false; } } /** * A date argument, as an instant. * * A bare "2026-03-14" is a CIVIL date — somebody picked a day off a calendar, * or an assistant repeated one back — and `new Date` reads it as UTC midnight, * which is the 13th for everyone west of Greenwich. It lands at 9am in their * own zone instead: the same hour every date this app sets itself uses, so a * follow-up picked by hand behaves exactly like one the app worked out. Values * that already carry a time are instants and pass through untouched. */ export function toDate( timeZone: string, value: Date | string | null | undefined, ): Date | null | undefined { if (value === undefined) return undefined; if (value === null || value === "") return null; if (typeof value === "string") { const civil = civilInstant(timeZone, value, 9); if (civil) return civil; } const d = value instanceof Date ? value : new Date(value); return Number.isNaN(d.getTime()) ? null : d; }