mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The opt-in timeline module gets its page (#613). At 768 px and up the record's lanes (life, conditions, allergies, medications, vaccinations, doctor and procedures, labs, documents) are drawn in one SVG on a shared time axis, with a year, month or week grid, open periods ending in an arrow, a dashed lead-in where a start is unknown, dose marks and pauses on the medication they belong to, a Today line and a selection line. Up to three neutral value lines run below as monthly means, chosen in a menu. Recharts stays for every value chart; this surface is plain SVG like the heatmaps. All geometry is pure (timeline-geometry.ts): window, scale, row packing and label placement through one occupancy map, so no label overlaps another label or a bar, none runs past the card edge, and labels that do not fit are left out rather than squeezed. The width comes from a ResizeObserver, so a docked day panel re-runs the layout. The bar under the chart names the selected month's entries as chips, the month's means and "Open 3 Jan.", which puts ?day= in the URL for the day layer. The chart is one tab stop: arrows move the selection by a day, a month or a year with the zoom, Home and End jump to the ends, Enter opens the day. A visually hidden table lists every entry for screen readers. The legend says once that stacked means at the same time, not cause and effect. Below 768 px the timeline is a chronicle, newest first by month or year, with an Ongoing block for what has no meaningful start, rails beside the rows an episode or course spans, and empty stretches named as such. Switching the module on in Settings opens the readiness inventory: each lane carries, is thin or is empty, with one link per gap and the verdict as a count. The same inventory sits as a dismissible card on the timeline while a gap is open, and under Layers as "Data coverage". Life events are entered in a sheet (title, kind, a date to the day, month or year, an optional end, an optional note) from the timeline, the capture picker and the dashboard's add menu; one surface line maps the capture kind to the timeline module, and it is offered in one's own record only. Deleting asks first. Server routes are built in parallel; the e2e journey answers them with the contract's shapes. Copy in all seven locales; the timeline and lifeEvents release-pending prefixes are gone.
304 lines
9.0 KiB
TypeScript
304 lines
9.0 KiB
TypeScript
/**
|
|
* 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<Record<RailLane, RailMode>>;
|
|
|
|
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<TimelineResponse, "lanes" | "standing">,
|
|
): 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<TimelineResponse, "lanes" | "notable">,
|
|
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<typeof railSpans>,
|
|
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<TimelineResponse, "lanes" | "notable" | "range">,
|
|
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<string, ChronicleEntry[]>();
|
|
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;
|
|
}
|