/** * The phone chronicle, as data (v1.42, #613). * * Below 768 px the timeline is not squeezed: it becomes a list, newest first, * grouped by month (or by year), which is also the list form of the chart for * a screen reader. What runs for years without a start that matters (a * chronic condition, a standing medication, an allergy) sits once in the * "Ongoing" block above the list instead of repeating in every month. What * runs for a while and ends (an episode, a winter course) is drawn as a thin * rail beside the rows it spans. * * Empty stretches are said out loud: "April to August 2025 ยท no entries" * rather than months silently skipped, so a gap reads as a gap and not as a * list that forgot something. */ import type { DayNotableKind, TimelineItem, TimelineLane, TimelineLaneKey, TimelineResponse, } from "@/lib/day/contract"; import { dayNumber, endOfMonth, monthsBetween, startOfMonth, } from "./timeline-dates"; import { LANE_ORDER, isSpan } from "./timeline-geometry"; export type ChronicleGrouping = "month" | "year"; /** One line in the chronicle. */ export type ChronicleEntry = | { kind: "item"; date: string; lane: TimelineLaneKey; item: TimelineItem; /** A point, the start of a period, or its end. */ role: "point" | "start" | "end"; /** Length of a closed period in days, on its end line. */ days: number | null; } | { kind: "notable"; date: string; notable: DayNotableKind }; /** Lanes whose closed periods are drawn as rails. */ export const RAIL_LANES = ["illness", "medications"] as const; export type RailLane = (typeof RAIL_LANES)[number]; /** * How a rail passes a row. The list runs newest first, so a period's end is * the upper of its two rows: on the end row the rail runs from the middle * down (`end`), on the start row from the top to the middle (`start`). */ export type RailMode = "full" | "start" | "end" | "point"; export type ChronicleRails = Partial>; export type ChronicleRow = | { type: "header"; group: string; rails: ChronicleRails } | { type: "entry"; entry: ChronicleEntry; rails: ChronicleRails } | { type: "gap"; from: string; to: string; rails: ChronicleRails }; export interface StandingChip { lane: TimelineLaneKey; id: string; label: string; /** More than one of the lane, folded into one chip ("2 allergies"). */ count: number; } /** The group a date belongs to: `YYYY-MM-01` or `YYYY-01-01`. */ export function groupOf(date: string, grouping: ChronicleGrouping): string { return grouping === "month" ? startOfMonth(date) : `${date.slice(0, 4)}-01-01`; } function groupEnd(group: string, grouping: ChronicleGrouping): string { return grouping === "month" ? endOfMonth(group) : `${group.slice(0, 4)}-12-31`; } function groupsBetween( from: string, to: string, grouping: ChronicleGrouping, ): string[] { if (grouping === "month") return monthsBetween(from, to).reverse(); const out: string[] = []; for (let y = Number(to.slice(0, 4)); y >= Number(from.slice(0, 4)); y--) { out.push(`${y}-01-01`); } return out; } /** * The Ongoing block: what has no start (`standing`) and every period still * open. Allergies fold into one chip when there is more than one. */ export function standingChips( timeline: Pick, ): StandingChip[] { const chips: StandingChip[] = []; const allergies: StandingChip[] = []; const push = (chip: StandingChip) => chip.lane === "allergies" ? allergies.push(chip) : chips.push(chip); for (const s of timeline.standing) { push({ lane: s.lane, id: s.id, label: s.label, count: 1 }); } for (const key of LANE_ORDER) { if (key === "life" || key === "cycle") continue; const lane = timeline.lanes.find((l) => l.key === key); for (const item of lane?.items ?? []) { if (item.open) push({ lane: key, id: item.id, label: item.label, count: 1 }); } } const ordered = chips.sort( (a, b) => LANE_ORDER.indexOf(a.lane) - LANE_ORDER.indexOf(b.lane), ); if (allergies.length === 1) ordered.push(allergies[0]); if (allergies.length > 1) { ordered.push({ lane: "allergies", id: "allergies", label: "", count: allergies.length, }); } return ordered; } /** Every dated line, newest first. */ export function chronicleEntries( timeline: Pick, today: string, ): ChronicleEntry[] { const out: ChronicleEntry[] = []; for (const lane of timeline.lanes) { for (const item of lane.items) { if (!isSpan(item)) { out.push({ kind: "item", date: item.start, lane: lane.key, item, role: "point", days: null, }); continue; } out.push({ kind: "item", date: item.start, lane: lane.key, item, role: "start", days: null, }); if (!item.open && item.end && item.end <= today) { out.push({ kind: "item", date: item.end, lane: lane.key, item, role: "end", days: dayNumber(item.end) - dayNumber(item.start) + 1, }); } } } for (const n of timeline.notable) { out.push({ kind: "notable", date: n.date, notable: n.kind }); } const laneRank = (e: ChronicleEntry) => e.kind === "item" ? LANE_ORDER.indexOf(e.lane) : LANE_ORDER.length; return out .filter((e) => e.date <= today) .sort((a, b) => a.date !== b.date ? a.date < b.date ? 1 : -1 : laneRank(a) - laneRank(b), ); } /** Closed periods of the rail lanes. */ function railSpans(lanes: readonly TimelineLane[]) { const out: Array<{ lane: RailLane; start: string; end: string }> = []; for (const lane of lanes) { if (!(RAIL_LANES as readonly string[]).includes(lane.key)) continue; for (const item of lane.items) { if (item.open || !item.end || item.end === item.start) continue; out.push({ lane: lane.key as RailLane, start: item.start, end: item.end, }); } } return out; } /** How every rail passes a row dated `date`. */ export function railsAt( spans: ReturnType, date: string, ): ChronicleRails { const rails: ChronicleRails = {}; for (const lane of RAIL_LANES) { let upper = false; let lower = false; for (const s of spans) { if (s.lane !== lane || date < s.start || date > s.end) continue; // The period continues to newer rows (above) unless it ends here, and // to older rows (below) unless it starts here. if (date < s.end) upper = true; if (date > s.start) lower = true; } const touches = spans.some( (s) => s.lane === lane && date >= s.start && date <= s.end, ); if (!touches) continue; rails[lane] = upper && lower ? "full" : upper ? "start" : lower ? "end" : "point"; } return rails; } /** * The rows: a header per group, its entries, and one gap row for every run * of groups without an entry. Groups run from `today`'s back to the oldest * entry, or to `dataFrom` when the record reaches further. */ export function buildChronicle( timeline: Pick, today: string, grouping: ChronicleGrouping, ): ChronicleRow[] { const entries = chronicleEntries(timeline, today); const spans = railSpans(timeline.lanes); const oldest = [ entries.at(-1)?.date, timeline.range.dataFrom ?? undefined, ].filter((d): d is string => !!d && d <= today); if (oldest.length === 0) return []; const first = oldest.sort()[0]; const byGroup = new Map(); for (const e of entries) { const g = groupOf(e.date, grouping); const list = byGroup.get(g); if (list) list.push(e); else byGroup.set(g, [e]); } const rows: ChronicleRow[] = []; let gapFrom: string | null = null; let gapTo: string | null = null; const flushGap = () => { if (gapFrom && gapTo) { // A period that spans the whole empty stretch keeps its rail through it. const from = gapFrom; const to = groupEnd(gapTo, grouping); const rails: ChronicleRails = {}; for (const lane of RAIL_LANES) { if ( spans.some((s) => s.lane === lane && s.start < from && s.end > to) ) { rails[lane] = "full"; } } rows.push({ type: "gap", from: gapFrom, to: gapTo, rails }); } gapFrom = gapTo = null; }; for (const group of groupsBetween(first, today, grouping)) { const list = byGroup.get(group); if (!list) { // Groups run newest first: the first empty one is the gap's newest end. gapTo = gapTo ?? group; gapFrom = group; continue; } flushGap(); const end = groupEnd(group, grouping); rows.push({ type: "header", group, rails: railsAt(spans, end < today ? end : today), }); for (const entry of list) { rows.push({ type: "entry", entry, rails: railsAt(spans, entry.date) }); } } flushGap(); return rows; }