mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
238 lines
7.6 KiB
TypeScript
238 lines
7.6 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,
|
|
TIMELINE_MAX_SERIES,
|
|
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 { 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";
|
|
|
|
/** 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 or there are more than {@link TIMELINE_MAX_SERIES}
|
|
* (the route answers 422).
|
|
*/
|
|
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),
|
|
),
|
|
];
|
|
if (keys.length > TIMELINE_MAX_SERIES) return null;
|
|
for (const key of keys) {
|
|
if (key !== MOOD_SERIES_KEY && !MEASUREMENT_TYPE_SET.has(key)) return null;
|
|
}
|
|
return keys;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
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 = reads[i].items
|
|
.filter((it) => overlaps(it, 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] = 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([]),
|
|
]);
|
|
|
|
return {
|
|
zoom: query.zoom,
|
|
range: { from, to, dataFrom },
|
|
lanes,
|
|
standing,
|
|
bucket,
|
|
series,
|
|
notable: notable.map(({ date, kind }) => ({ date, kind })),
|
|
};
|
|
}
|