Files
HealthLog/src/lib/tz/date-only.ts
T
Marc-André Bombeck 801295f55a fix(dates): read date-only lab and document dates as calendar dates
A date-only value is stored at 12:00 UTC. The lab list and history, the
document cards and pickers, the document date suggestion, extracted
fact dates, source-picker rows and the compliance and mood heatmaps
formatted that instant in the reader's zone, which is the next day from
UTC+12 to UTC+14.

`useCalendarDate` formats such a value through the UTC-pinned calendar
formatter the vaccination list already uses, with the same field order
and locale as instant dates. Lab readings hold both kinds, so
`useLabDate` reads a noon-UTC anchor as its calendar date and a reading
typed with its time of day in the reader's zone. A document without a
filing date shows its upload day, an instant, in the reader's zone.
2026-10-01 22:14:07 +02:00

101 lines
4.0 KiB
TypeScript

/**
* Date-only values: a calendar date (`YYYY-MM-DD`) with no time of day — a
* report's stated date, a document's filing date, a backup's label for a
* `@db.Date` column.
*
* Such a value is stored in a timestamp column as noon UTC. Noon keeps the
* date on the same calendar day whether it is read back as a UTC label
* ({@link dateOnlyKey}) or formatted in any zone from UTC-11 to UTC+11. The
* older anchor, UTC midnight, sits on the previous evening everywhere west
* of UTC, which is how a stated report date came to show a day early.
*
* Rows written at UTC midnight before the switch still read back to the same
* key through {@link dateOnlyKey}, so the two anchors coexist without a
* migration.
*/
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/u;
/** True for a strict `YYYY-MM-DD` string. */
export function isDateOnlyKey(value: string): boolean {
return DATE_ONLY_RE.test(value);
}
/**
* True for a `YYYY-MM-DD` string that names a real calendar date.
* `2026-02-30` passes the shape check but overflows to 2 March when parsed,
* so a reader would return a different day than the one asked for.
*/
export function isCalendarDateKey(value: string): boolean {
if (!DATE_ONLY_RE.test(value)) return false;
const parsed = new Date(`${value}T00:00:00.000Z`);
return (
!Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value)
);
}
/**
* The calendar date a free-form date string states, or `null`. An ISO string
* (`2026-06-10`, `2026-06-10T08:00:00+09:00`) states the date it starts with.
* Anything else (`June 10, 2026`) is parsed the way `Date` parses it, as local
* time in the process zone, so the date is read back in that same zone;
* reading it as UTC put it on the previous day for a process east of UTC.
*/
export function statedDateKey(raw: string): string | null {
const trimmed = raw.trim();
const isoPrefix = /^(\d{4}-\d{2}-\d{2})(?:$|T|\s)/u.exec(trimmed);
if (isoPrefix) {
return isCalendarDateKey(isoPrefix[1]) ? isoPrefix[1] : null;
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return null;
const pad = (n: number) => String(n).padStart(2, "0");
return `${String(parsed.getFullYear()).padStart(4, "0")}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
}
/** The instant a date-only value is stored as: 12:00 UTC on that date. */
export function dateOnlyAtNoonUtc(key: string): Date {
return new Date(`${key}T12:00:00.000Z`);
}
/**
* True when a stored instant is the noon-UTC anchor of a date-only value.
*
* For a column that holds both kinds (a lab reading typed with its time of
* day, or imported from a report that states only the date), this is how a
* reader tells them apart. A reading typed at exactly 12:00:00.000 UTC reads
* as its UTC calendar date, which is its local date everywhere from UTC-11
* to UTC+11.
*/
export function isNoonUtcAnchor(value: Date): boolean {
return (
value.getUTCHours() === 12 &&
value.getUTCMinutes() === 0 &&
value.getUTCSeconds() === 0 &&
value.getUTCMilliseconds() === 0
);
}
/**
* The calendar date of a stored date-only value (or of a `@db.Date` column,
* which Prisma hands back as UTC midnight). Only for values that ARE dates:
* the day an instant fell on for a person is `userDayKey(instant, tz)`.
*/
export function dateOnlyKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* Calendar date `key` as UTC midnight. A LABEL, never the instant the day
* began for anyone: it is how a `@db.Date` column holds a date (Prisma reads
* and writes those as UTC midnight), how the day rollups key `bucketStart`,
* and how a day-keyed series places a point on a time axis. Use it to compare
* such a column with a day (`endsOn >= dayKeyAsUtcMidnight(todayKey)` keeps a
* course through its last day, where comparing with the instant `now` dropped
* it) or to build such a label. The first instant of a person's day is
* `startOfLocalDayKey(key, tz)`.
*/
export function dayKeyAsUtcMidnight(key: string): Date {
return new Date(`${key}T00:00:00.000Z`);
}