src/lib/time.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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;
}