Files
HealthLog/src/components/timeline/timeline-dates.ts
T
Marc-André Bombeck 61155d1856 feat(timeline): add a chosen range as a fourth zoom
Beside All, 12 months and 3 months the timeline takes a range of the
person's own choosing, from and to, through the app's date field. Each
change is clamped softly: never past today, never before the record's
first entry, never inverted, never longer than fifteen years. On a phone,
where the chronicle has no zoom control, a range button opens the same
fields with a way back to the whole record. A range with nothing in it
keeps its fields instead of the empty-record notice.

The zoom and the range live in the URL (zoom, from, to), so Back and a
bookmark return to them; a parameter that does not read falls back to
All. The API accepts zoom=range with both ends, at most fifteen years,
and picks the bucket by its length: quarters from two years, months from
120 days, weeks from 42 days and single days below, a new day bucket
whose dashed bridge spans at most three missing days. A range borrows
the grid and the keyboard steps of the fixed zoom nearest its length.
The selection bar heads a day bucket with that day.
2026-10-09 10:02:37 +02:00

225 lines
7.5 KiB
TypeScript

/**
* Calendar arithmetic for the timeline (v1.42, #613).
*
* Every date the timeline handles is a stated calendar date (`YYYY-MM-DD`):
* the server cut it in the record's zone already, so nothing here may read it
* through the browser's zone again. Dates become whole day numbers since the
* epoch (UTC midnight), which keeps the x-scale linear across daylight-saving
* changes and makes "one day to the right" exactly `+1`.
*
* Formatting reads the same UTC instant back with `timeZone: "UTC"`, so a
* date never moves by a day for a reader east or west of Greenwich.
*/
import type { TimelineBucket } from "@/lib/day/contract";
const MS_PER_DAY = 86_400_000;
/** Days since 1970-01-01 for a `YYYY-MM-DD` key. */
export function dayNumber(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / MS_PER_DAY);
}
/** The `YYYY-MM-DD` key of a day number. */
export function dayKey(day: number): string {
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
return new Date(day * MS_PER_DAY).toISOString().slice(0, 10);
}
/** `YYYY-MM` of a date key. */
export function monthOf(key: string): string {
return key.slice(0, 7);
}
/** First day of the month a key falls in. */
export function startOfMonth(key: string): string {
return `${key.slice(0, 7)}-01`;
}
/** Last day of the month a key falls in. */
export function endOfMonth(key: string): string {
const [y, m] = key.split("-").map(Number);
return dayKey(Math.round(Date.UTC(y, m, 0) / MS_PER_DAY));
}
/**
* The key `months` calendar months away, clamped to the target month's
* length (31 January plus one month is 28 or 29 February).
*/
export function addMonths(key: string, months: number): string {
const [y, m, d] = key.split("-").map(Number);
const target = new Date(Date.UTC(y, m - 1 + months, 1));
const lastDay = new Date(
Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
).getUTCDate();
target.setUTCDate(Math.min(d, lastDay));
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a calendar key held as UTC midnight, never an instant
return target.toISOString().slice(0, 10);
}
/* ─── Series buckets ──────────────────────────────────────────────────────── */
/** The first day of the bucket `key` falls in. Weeks start on Monday. */
export function bucketStart(key: string, bucket: TimelineBucket): string {
if (bucket === "day") return key;
if (bucket === "month") return startOfMonth(key);
if (bucket === "quarter") {
const month = Number(key.slice(5, 7));
const first = month - ((month - 1) % 3);
return `${key.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
}
const day = dayNumber(key);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
return dayKey(day - ((((day - 4) % 7) + 7) % 7));
}
/** The first day after the bucket that starts on `start`. */
export function bucketAfter(start: string, bucket: TimelineBucket): string {
if (bucket === "day") return dayKey(dayNumber(start) + 1);
if (bucket === "month") return addMonths(start, 1);
if (bucket === "quarter") return addMonths(start, 3);
return dayKey(dayNumber(start) + 7);
}
/**
* A running number for the bucket that starts on `start`: neighbouring
* buckets differ by one, so a difference counts the buckets between.
*/
export function bucketIndex(start: string, bucket: TimelineBucket): number {
if (bucket === "day") return dayNumber(start);
if (bucket === "week") return Math.floor((dayNumber(start) - 4) / 7);
const months = Number(start.slice(0, 4)) * 12 + Number(start.slice(5, 7)) - 1;
return bucket === "quarter" ? Math.floor(months / 3) : months;
}
/** Every `YYYY-MM-01` from the month of `from` through the month of `to`. */
export function monthsBetween(from: string, to: string): string[] {
const out: string[] = [];
let cursor = startOfMonth(from);
const last = startOfMonth(to);
while (cursor <= last) {
out.push(cursor);
cursor = addMonths(cursor, 1);
}
return out;
}
/** Today's calendar date in an IANA zone, as `YYYY-MM-DD`. */
export function todayKeyIn(timeZone: string | undefined, now = new Date()) {
try {
// `en-CA` formats as YYYY-MM-DD.
return new Intl.DateTimeFormat("en-CA", {
timeZone: timeZone || undefined,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
} catch {
// An unknown zone name: the browser's own calendar day is the honest
// fallback, not UTC's.
const pad = (n: number) => String(n).padStart(2, "0");
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
}
function utcDate(key: string): Date {
return new Date(dayNumber(key) * MS_PER_DAY);
}
function format(
key: string,
intlLocale: string,
options: Intl.DateTimeFormatOptions,
): string {
return new Intl.DateTimeFormat(intlLocale, {
...options,
timeZone: "UTC",
}).format(utcDate(key));
}
/** "Januar 2026". */
export function formatMonthYear(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "long", year: "numeric" });
}
/** "Jan. 2026". */
export function formatMonthYearShort(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "short", year: "numeric" });
}
/** "3. Jan.". */
export function formatDayMonth(key: string, intlLocale: string): string {
return format(key, intlLocale, { day: "numeric", month: "short" });
}
/** "3. Jan. 2026". */
export function formatDayMonthYear(key: string, intlLocale: string): string {
return format(key, intlLocale, {
day: "numeric",
month: "short",
year: "numeric",
});
}
/** "3. Januar 2026". */
export function formatDayMonthYearLong(
key: string,
intlLocale: string,
): string {
return format(key, intlLocale, {
day: "numeric",
month: "long",
year: "numeric",
});
}
/** "Jan". Axis labels. */
export function formatMonthShort(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "short" });
}
/** "Januar". */
export function formatMonthLong(key: string, intlLocale: string): string {
return format(key, intlLocale, { month: "long" });
}
/**
* A date at the precision it was recorded with: "3. Jan. 2026", "Sept.
* 2023" or "2019". Life events and stand-in dates use this, so a month the
* person gave is never shown as its first day.
*/
export function formatAtPrecision(
key: string,
precision: "DAY" | "MONTH" | "YEAR",
intlLocale: string,
): string {
if (precision === "YEAR") return key.slice(0, 4);
if (precision === "MONTH") return formatMonthYearShort(key, intlLocale);
return formatDayMonthYear(key, intlLocale);
}
/**
* How a series bucket reads: "Januar 2026" for a month, otherwise its first
* and last day for the caller's "{from} to {to}" ("Jan to März 2026",
* "5. Jan. to 11. Jan. 2026").
*/
export function formatBucket(
start: string,
bucket: TimelineBucket,
intlLocale: string,
): string | { from: string; to: string } {
if (bucket === "day") return formatDayMonthYear(start, intlLocale);
if (bucket === "month") return formatMonthYear(start, intlLocale);
const last = dayKey(dayNumber(bucketAfter(start, bucket)) - 1);
if (bucket === "quarter") {
return {
from: formatMonthShort(start, intlLocale),
to: formatMonthYearShort(last, intlLocale),
};
}
return {
from: formatDayMonth(start, intlLocale),
to: formatDayMonthYear(last, intlLocale),
};
}