Files
HealthLog/src/lib/timeline/load-timeline.ts
T
Marc-André Bombeck 9f3da5ac7d fix(timeline): keep every medication still taken on its row, and the chronicle honest
An active medication whose courses had all ended had no stretch left in
the current window: the courses decided when it was taken, none reached
into the window, and the medication's own open span was set aside because
it had courses. The row disappeared while the day view listed the
medication as running. The time since its last course now stands in as
its stretch, open to today.

The server cut a lane's items at the window edge, so a medication arrived
with only some of its courses and without the dose change that set the
dose of the piece running into the window. A medication touching the
window is now sent whole.

On a phone, every entry wears its lane's colour: the icon in the lane
colour, and a medication listed inside an illness keeps a dot on its own
rail instead of reading as the illness's orange. A month without an event
is no longer called empty when it had readings (its header stays and
carries the means) or while a medication was taken in it; only a month
with none of the three gets the "No entries" line.
2026-10-09 16:05:40 +02:00

299 lines
9.8 KiB
TypeScript

/**
* `loadTimeline`: the record over the years (v1.42, #613).
*
* The lanes read the whole history of their tables (they are small: a few
* hundred spans and points even on a long record), then the window decides
* what is sent. The series and the notable days are the expensive part and
* read only the window.
*
* Windows by zoom, when the request names none: `quarter` the last 91 days,
* `year` the last 365, `all` from the earliest date the record carries. A
* `range` always names its own (the query schema requires both ends). The
* notable days of an `all` window are computed over its last three years at
* most: an extreme is "the highest for at least three months", and older
* history is what the comparison reads, not what it marks.
*/
import { MeasurementType as MeasurementTypes } from "@/generated/prisma/enums";
import type { MeasurementType } from "@/generated/prisma/enums";
import {
DAY_NOTABLE_MAX_SPAN_DAYS,
TIMELINE_LANE_KEYS,
type TimelineBucket,
type TimelineItem,
type TimelineQuery,
type TimelineResponse,
type TimelineZoom,
} from "@/lib/day/contract";
import { loadNotableRange } from "@/lib/day/notable";
import { measurementTypeVisible } from "@/lib/day/sections";
import { prisma } from "@/lib/db";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
LANE_READERS,
laneVisible,
type StandingItem,
type TimelineAccess,
} from "@/lib/timeline/lanes";
import { loadTimelineSeries, MOOD_SERIES_KEY } from "@/lib/timeline/series";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
/** The series sent when the request names none. */
export const DEFAULT_TIMELINE_SERIES: readonly MeasurementType[] = [
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"WEIGHT",
];
/** An `all` window longer than this many days averages quarters. */
const QUARTER_BUCKET_MIN_DAYS = 2 * 365;
/**
* The bucket for a chosen range, by its length: about the point count the
* fixed zooms draw (a dozen to a few dozen), never one point per reading.
* Two years and more average quarters (eight and up), four months and more
* months (four to twenty-four), six weeks and more weeks (six to
* seventeen), and anything shorter single days (at most six weeks of them).
*/
export const RANGE_BUCKET_MIN_DAYS: Readonly<
Record<Exclude<TimelineBucket, "day">, number>
> = {
quarter: 2 * 365,
month: 120,
week: 42,
};
export function rangeBucket(from: string, to: string): TimelineBucket {
const days = daysBetweenDateKeys(from, to) + 1;
if (days >= RANGE_BUCKET_MIN_DAYS.quarter) return "quarter";
if (days >= RANGE_BUCKET_MIN_DAYS.month) return "month";
if (days >= RANGE_BUCKET_MIN_DAYS.week) return "week";
return "day";
}
/**
* The span one series point averages. A point per day or per week over
* years drew a line of fragments wherever readings came in bursts, so the
* bucket grows with the window: weeks in three months (thirteen points),
* months in a year (twelve), quarters once `all` reaches back more than two
* years. An `all` window shorter than that keeps months, so a young record
* does not shrink to a handful of quarters.
*/
export function timelineBucket(
zoom: TimelineZoom,
from: string,
to: string,
): TimelineBucket {
if (zoom === "range") return rangeBucket(from, to);
if (zoom === "quarter") return "week";
if (zoom === "year") return "month";
const days = daysBetweenDateKeys(from, to);
return days > QUARTER_BUCKET_MIN_DAYS ? "quarter" : "month";
}
const ZOOM_DAYS: Readonly<
Record<Exclude<TimelineZoom, "all" | "range">, number>
> = {
year: 365,
quarter: 91,
};
const MEASUREMENT_TYPE_SET: ReadonlySet<string> = new Set(
Object.values(MeasurementTypes),
);
/**
* The series keys a `values` parameter names, or null when one is not a
* series this API knows (the route answers 422). There is no cap on the
* count: a name given twice counts once, so the list never grows past the
* names that exist, and each series is its own row under the lanes.
*/
export function parseSeriesKeys(raw: string | undefined): string[] | null {
if (raw === undefined) return [...DEFAULT_TIMELINE_SERIES];
const keys = [
...new Set(
raw
.split(",")
.map((k) => k.trim())
.filter((k) => k.length > 0),
),
];
for (const key of keys) {
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
}
return keys;
}
/**
* Every series the record can show, whatever the window: the measurement
* types with live readings the caller may see, events left out (they are
* occurrences, not values a mean can be taken of), and the mood score when
* it is visible and has an entry. Measurement types come in the enum's
* order, mood last.
*/
export async function loadAvailableSeries(args: {
recordId: string;
access: TimelineAccess;
}): Promise<string[]> {
const { recordId, access } = args;
const moodVisible =
access.modules.mood !== false && access.domainVisible("mind");
const [types, mood] = await Promise.all([
access.domainVisible("measurements")
? listLiveMeasurementTypes(recordId)
: Promise.resolve([] as MeasurementType[]),
moodVisible
? prisma.moodEntry.findFirst({
where: { userId: recordId, deletedAt: null },
select: { id: true },
})
: Promise.resolve(null),
]);
return [
...types.filter(
(type) =>
!EVENT_MEASUREMENT_TYPES.has(type) &&
measurementTypeVisible(type, access.modules),
),
...(mood ? [MOOD_SERIES_KEY] : []),
];
}
/** Whether an item touches `[from, to]`. An open span runs up to today. */
export function overlaps(
item: Pick<TimelineItem, "start" | "end" | "open">,
from: string,
to: string,
today: string,
): boolean {
const end = item.end ?? (item.open ? today : item.start);
return item.start <= to && end >= from;
}
/**
* The items of a lane that touch `[from, to]`, kept whole per `group`: when
* one item of a medication touches the window, every item of it is sent.
* The client reads a medication from all of them together
* (`medication-rows.ts`): its courses decide the stretches it was taken, and
* the dose of a piece that runs into the window was set by a dose change
* that may lie before it. Cut at the window edge, a course outside it was
* lost (the medication's own span stood in for it and bridged the gap), and
* the first piece in the window lost its dose.
*/
export function keepTouchingGroups(
items: readonly TimelineItem[],
from: string,
to: string,
today: string,
): TimelineItem[] {
const touching = new Set<string>();
for (const it of items) {
if (overlaps(it, from, to, today)) touching.add(it.group ?? it.id);
}
return items.filter((it) => touching.has(it.group ?? it.id));
}
export async function loadTimeline(args: {
recordId: string;
query: TimelineQuery;
seriesKeys: readonly string[];
access: TimelineAccess;
tz: string;
now?: Date;
}): Promise<TimelineResponse> {
const { recordId, query, access, tz } = args;
const now = args.now ?? new Date();
const today = userDayKey(now, tz);
const frame = { userId: recordId, tz, access };
const visibleLanes = TIMELINE_LANE_KEYS.filter((key) =>
laneVisible(key, access),
);
const measurementsVisible = access.domainVisible("measurements");
const [reads, firstReading] = await Promise.all([
Promise.all(visibleLanes.map((key) => LANE_READERS[key](frame))),
measurementsVisible
? prisma.measurement.findFirst({
where: { userId: recordId, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true },
})
: Promise.resolve(null),
]);
const starts = reads.flatMap((r) => r.items.map((i) => i.start));
if (firstReading) starts.push(userDayKey(firstReading.measuredAt, tz));
const dataFrom = starts.length > 0 ? starts.sort()[0] : null;
const to = query.to ?? today;
const from =
query.from ??
(query.zoom === "all" || query.zoom === "range"
? dataFrom !== null && dataFrom < to
? dataFrom
: shiftDateKey(to, -364)
: shiftDateKey(to, -(ZOOM_DAYS[query.zoom] - 1)));
const lanes: TimelineResponse["lanes"] = [];
const standing: StandingItem[] = [];
visibleLanes.forEach((key, i) => {
const items = keepTouchingGroups(reads[i].items, from, to, today).sort(
(a, b) => a.start.localeCompare(b.start),
);
if (items.length > 0) lanes.push({ key, items });
standing.push(...reads[i].standing);
});
const seriesKeys = args.seriesKeys.filter((key) =>
key === MOOD_SERIES_KEY
? access.modules.mood !== false && access.domainVisible("mind")
: measurementsVisible &&
measurementTypeVisible(key as MeasurementType, access.modules),
);
const priorityJson = measurementsVisible
? await loadUserSourcePriority(recordId)
: null;
const notableFrom =
shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1)) > from
? shiftDateKey(to, -(DAY_NOTABLE_MAX_SPAN_DAYS - 1))
: from;
const bucket = timelineBucket(query.zoom, from, to);
const [series, notable, availableSeries] = await Promise.all([
loadTimelineSeries({
userId: recordId,
keys: seriesKeys,
from,
to,
tz,
bucket,
priorityJson,
now,
}),
measurementsVisible
? loadNotableRange({
userId: recordId,
from: notableFrom,
to,
tz,
priorityJson,
typeVisible: (t) => measurementTypeVisible(t, access.modules),
gaps: false,
})
: Promise.resolve([]),
loadAvailableSeries({ recordId, access }),
]);
return {
zoom: query.zoom,
range: { from, to, dataFrom },
lanes,
standing,
bucket,
series,
availableSeries,
notable: notable.map(({ date, kind }) => ({ date, kind })),
};
}