// The calendar windowing Home's two trend charts share: which slice of a // series is on screen, what it's called, which slices are worth offering, and how // to step between them. // // Nothing here is about gas, and the alternative is a second copy of a fortnight // of calendar edge cases in `dive-activity.ts`: the two cards sit one above the // other and their period controls are meant to behave identically, which is a // promise one shared module keeps and two similar ones only make. // // Everything reads and builds timestamps in UTC (`Date.UTC`, `getUTC*`) because // every time fed to it is a wall-clock instant - a dive's own local time encoded // into a UTC-reading number by `diveWallClockTime()`, or a calendar bucket built // with `Date.UTC` by `activityDays()`. Using the local getters would re-interpret // those through the viewer's timezone and drop a New Year's Eve dive into the // wrong year depending on where it's being looked at from. // How much of a series is on screen at once. A career in one frame shows the long // arc and buries a single trip; a month shows the trip and no arc. Both are worth // looking at, so it's a switch rather than a choice made once here. export type ChartScope = "all" | "year" | "month"; export const CHART_SCOPES: ChartScope[] = ["all", "year", "month"]; export const CHART_SCOPE_LABELS: Record = { all: "All", year: "Year", month: "Month", }; // A month's name, 1-12, from the platform rather than a hardcoded table, so it // reads the same way as every other date in the app (see `periodLabel`). The // year is arbitrary and never shown - this is naming a month, not a date - and // `timeZone: "UTC"` keeps the constructed date from sliding into the previous // month for viewers west of Greenwich. export function monthName(month: number, style: "short" | "long"): string { return new Date(Date.UTC(2001, month - 1, 1)).toLocaleDateString("en-US", { month: style, timeZone: "UTC", }); } // The half-open `[start, end)` bounds of the calendar period containing `anchor`. export function periodRange( anchor: number, scope: "year" | "month", ): { start: number; end: number } { const date = new Date(anchor); const year = date.getUTCFullYear(); if (scope === "year") { return { start: Date.UTC(year, 0, 1), end: Date.UTC(year + 1, 0, 1) }; } const month = date.getUTCMonth(); return { start: Date.UTC(year, month, 1), end: Date.UTC(year, month + 1, 1) }; } // What to call the period on screen: "All time", "2026", "Apr 2026". The month // is short by default so the "vs " line fits at phone width; the period // picker has a fixed width of its own and names it in full. export function periodLabel( anchor: number, scope: ChartScope, month: "short" | "long" = "short", ): string { if (scope === "all") return "All time"; return new Date(anchor).toLocaleDateString("en-US", { ...(scope === "month" ? { month } : {}), year: "numeric", timeZone: "UTC", }); } export interface ChartPeriod { // The period's start, as a stable identity for it. This, not `anchor`, is what // a dropdown option's value has to be: an arbitrary sample's timestamp is not a // value the current anchor can be compared against, and a `