Files
HealthLog/src/lib/timeline/readiness.ts
T
Marc-André Bombeck 0dcafeeb70 fix(visits): open an upcoming appointment where it lives
Visits are the second tab of `/checkups`, and the bare path opens the
first one, the preventive-care list, which never shows an appointment.
Every surface that listed an upcoming appointment linked it there: the
dashboard's next-appointment line, the Today rail item, the Today
overview fact and the appointment's push notification. A tap on the
appointment landed on the list it is not on.

Each now opens the visit itself (`/checkups?visit=<id>`), or the visits
tab (`/checkups?view=visits`) for a line naming several appointments.
The checkups page reads `view` to pick a tab. The rail item stops
borrowing the check-up item's action: it carries its own label
("View appointment") and intent `visit.view`. The appointment push names
its visit through the producer's `metadata.url` override; a check-up's
push keeps the checkups list.

The same mis-route sat on three links that lead to visits without
being upcoming: a condition's visit rows, the address book's back link
and the onboarding and timeline prompts to add a visit.

`visit-link-guard.test.ts` keeps the visit paths in the builder and
counts every bare `/checkups` per file, each with the reason it names
the checkups list.
2026-10-21 12:22:36 +02:00

534 lines
17 KiB
TypeScript

/**
* What the timeline can already show, lane by lane (v1.42, #613).
*
* Counts and keys, never a score. Each lane is `carries`, `thin` or `empty`
* by a rule a person can check against their own record, and each gap names
* exactly one in-app place where it can be closed. The verdict is one of two
* words: `carries` from three carrying lanes including the values, `thin`
* otherwise. A lane of a switched-off module is not listed at all, not even
* as a gap, and neither is one the caller's grant does not cover.
*
* values a core value on at least half of the last 13 weeks
* illness at least one episode (every episode has an onset)
* allergies at least one with an onset; only undated ones is thin and
* is no gap, a lasting allergy needs no date
* medications every medication has a start (or a first intake to stand
* in); some without one is thin, with a link to the first
* vaccinations at least one dose
* visits at least one completed visit; only planned ones is thin
* labs at least one result
* documents at least one dated document; only undated ones is thin
* cycle, mood, environment
* entries on at least half of the last 13 weeks
* life at least one life event
*/
import type { MeasurementType } from "@/generated/prisma/enums";
import type {
TimelineReadinessKey,
TimelineReadinessLane,
TimelineReadinessResponse,
} from "@/lib/day/contract";
import { readLocalDaysWithReadings } from "@/lib/day/daily-stats";
import { VISITS_PATH } from "@/lib/encounters/visit-links";
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
import { prisma } from "@/lib/db";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
import { measurementTypeVisible } from "@/lib/day/sections";
import { laneVisible, type TimelineAccess } from "@/lib/timeline/lanes";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { bucketKey } from "@/lib/timeline/series";
/** The values that make the values lane carry. */
const CORE_VALUE_TYPES: readonly MeasurementType[] = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"PULSE",
"RESTING_HEART_RATE",
"BLOOD_GLUCOSE",
];
/** The weeks a regular lane is judged over. */
export const READINESS_WEEKS = 13;
type LaneResult = Omit<TimelineReadinessLane, "key"> & {
/** The earliest date the lane reaches back to, when it carries. */
since: string | null;
};
function lane(
status: LaneResult["status"],
count: number,
since: string | null,
extra: Partial<Pick<LaneResult, "detail" | "gaps">> = {},
): LaneResult {
return { status, count, since, detail: null, gaps: [], ...extra };
}
/** How many of the last {@link READINESS_WEEKS} weeks hold a day. */
export function coveredWeeks(days: Iterable<string>, today: string): number {
const first = shiftDateKey(today, -7 * READINESS_WEEKS + 1);
const weeks = new Set<string>();
for (const day of days) {
if (day >= first && day <= today) weeks.add(bucketKey(day, "week"));
}
return weeks.size;
}
/** Nothing yet: the one detail an empty lane carries. */
const EMPTY_DETAIL = { key: "empty", params: {} } as const;
/** A gap with its one in-app link. */
function gap(key: string, count: number, href: string) {
return { key, count, href };
}
/**
* A lane judged by how regularly it is filled: `carries` with entries on at
* least half of the last {@link READINESS_WEEKS} weeks, `thin` with fewer,
* `empty` with none.
*/
function regular(
weeks: number,
total: number,
since: string | null,
detail: LaneResult["detail"],
emptyGap: ReturnType<typeof gap> | null,
): LaneResult {
const status =
weeks * 2 >= READINESS_WEEKS ? "carries" : total > 0 ? "thin" : "empty";
if (status === "empty") {
return lane("empty", 0, null, {
detail: EMPTY_DETAIL,
gaps: emptyGap ? [emptyGap] : [],
});
}
return lane(status, total, status === "carries" ? since : null, { detail });
}
interface Frame {
userId: string;
tz: string;
today: string;
/** The regular-lane window, `[windowStart, windowEnd)`. */
windowStart: Date;
windowEnd: Date;
}
async function valuesLane(frame: Frame, types: MeasurementType[]) {
const emptyGap = gap("valuesEmpty", 0, "/settings/sources");
if (types.length === 0) {
return lane("empty", 0, null, { detail: EMPTY_DETAIL, gaps: [emptyGap] });
}
const [days, first, present] = await Promise.all([
readLocalDaysWithReadings({
userId: frame.userId,
types,
from: frame.windowStart,
to: frame.windowEnd,
tz: frame.tz,
}),
prisma.measurement.findFirst({
where: { userId: frame.userId, deletedAt: null, type: { in: types } },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true },
}),
listLiveMeasurementTypes(frame.userId, { types }),
]);
const since = first ? userDayKey(first.measuredAt, frame.tz) : null;
return regular(
coveredWeeks(days, frame.today),
days.size,
since,
{
key: "values",
params: { count: present.length, ...(since ? { since } : {}) },
},
emptyGap,
);
}
async function moodLane(frame: Frame) {
const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1);
const [rows, earliest, total] = await Promise.all([
prisma.moodEntry.findMany({
where: { userId: frame.userId, deletedAt: null, date: { gte: first } },
select: { date: true },
distinct: ["date"],
}),
prisma.moodEntry.findFirst({
where: { userId: frame.userId, deletedAt: null },
orderBy: { date: "asc" },
select: { date: true },
}),
prisma.moodEntry.count({
where: { userId: frame.userId, deletedAt: null },
}),
]);
const since = earliest?.date ?? null;
return regular(
coveredWeeks(
rows.map((r) => r.date),
frame.today,
),
total,
since,
{ key: "mood", params: { count: total, ...(since ? { since } : {}) } },
null,
);
}
async function cycleLane(frame: Frame) {
const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1);
const [rows, earliest, cycles] = await Promise.all([
prisma.cycleDayLog.findMany({
where: { userId: frame.userId, deletedAt: null, date: { gte: first } },
select: { date: true },
}),
prisma.menstrualCycle.findFirst({
where: { userId: frame.userId, deletedAt: null, isPredicted: false },
orderBy: { startDate: "asc" },
select: { startDate: true },
}),
prisma.menstrualCycle.count({
where: { userId: frame.userId, deletedAt: null, isPredicted: false },
}),
]);
return regular(
coveredWeeks(
rows.map((r) => r.date),
frame.today,
),
cycles,
earliest?.startDate ?? null,
{ key: "cycle", params: { count: cycles } },
null,
);
}
async function environmentLane(frame: Frame) {
const first = shiftDateKey(frame.today, -7 * READINESS_WEEKS + 1);
const [rows, earliest, total] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId: frame.userId, date: { gte: first } },
select: { date: true },
}),
prisma.environmentContext.findFirst({
where: { userId: frame.userId },
orderBy: { date: "asc" },
select: { date: true },
}),
prisma.environmentContext.count({ where: { userId: frame.userId } }),
]);
return regular(
coveredWeeks(
rows.map((r) => r.date),
frame.today,
),
total,
earliest?.date ?? null,
{ key: "environment", params: { count: total } },
null,
);
}
async function illnessLane(frame: Frame) {
const where = { userId: frame.userId, deletedAt: null };
const [count, chronic, first] = await Promise.all([
prisma.illnessEpisode.count({ where }),
prisma.illnessEpisode.count({
where: { ...where, lifecycle: "CHRONIC_ONGOING" },
}),
prisma.illnessEpisode.findFirst({
where,
orderBy: { onsetAt: "asc" },
select: { onsetAt: true },
}),
]);
return count > 0
? lane(
"carries",
count,
first ? userDayKey(first.onsetAt, frame.tz) : null,
{ detail: { key: "illness", params: { count, chronic } } },
)
: lane("empty", 0, null, {
detail: EMPTY_DETAIL,
gaps: [gap("illnessEmpty", 0, "/illness")],
});
}
async function allergiesLane(frame: Frame) {
const rows = await prisma.allergy.findMany({
where: { userId: frame.userId, deletedAt: null },
select: { onsetAt: true },
});
if (rows.length === 0) {
return lane("empty", 0, null, { detail: EMPTY_DETAIL });
}
const detail = { key: "allergies", params: { count: rows.length } };
const dated = rows.filter((r) => r.onsetAt !== null);
if (dated.length > 0) {
const since = dated
.map((r) => userDayKey(r.onsetAt as Date, frame.tz))
.sort()[0];
return lane("carries", rows.length, since, { detail });
}
// Undated allergies are standing items, not a shortcoming: no gap.
return lane("thin", rows.length, null, { detail });
}
async function medicationsLane(frame: Frame) {
const [meds, intakes] = await Promise.all([
prisma.medication.findMany({
where: { userId: frame.userId },
select: { id: true, startsOn: true },
orderBy: { createdAt: "asc" },
}),
prisma.medicationIntakeEvent.groupBy({
by: ["medicationId"],
where: {
userId: frame.userId,
deletedAt: null,
skipped: false,
takenAt: { not: null },
...TRACKED_INTAKE_EVENT_WHERE,
},
_min: { takenAt: true },
}),
]);
if (meds.length === 0) {
return lane("empty", 0, null, { detail: EMPTY_DETAIL });
}
const firstIntake = new Map(
intakes.map((r) => [r.medicationId, r._min.takenAt]),
);
const starts: string[] = [];
const missing: string[] = [];
for (const med of meds) {
// A first intake stands in for a missing start, as on the lane itself.
const intake = firstIntake.get(med.id);
if (med.startsOn) starts.push(dateOnlyKey(med.startsOn));
else if (intake) starts.push(userDayKey(intake, frame.tz));
else missing.push(med.id);
}
const since = starts.sort()[0] ?? null;
if (missing.length === 0) {
return lane("carries", meds.length, since, {
detail: { key: "medicationsDated", params: { count: meds.length } },
});
}
return lane("thin", meds.length, null, {
detail: {
key: "medications",
params: { count: meds.length, missingStart: missing.length },
},
gaps: [
gap(
"medicationsWithoutStart",
missing.length,
`/medications/${missing[0]}?edit=1`,
),
],
});
}
async function vaccinationsLane(frame: Frame) {
const where = { userId: frame.userId, deletedAt: null };
const [count, first] = await Promise.all([
prisma.vaccinationRecord.count({ where }),
prisma.vaccinationRecord.findFirst({
where,
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
}),
]);
if (!first) {
return lane("empty", 0, null, {
detail: EMPTY_DETAIL,
gaps: [gap("vaccinationsEmpty", 0, "/vaccinations")],
});
}
return lane("carries", count, userDayKey(first.occurredAt, frame.tz), {
detail: { key: "vaccinations", params: { count } },
});
}
async function visitsLane(frame: Frame) {
const where = { userId: frame.userId, deletedAt: null };
const [done, procedures, planned, first] = await Promise.all([
prisma.encounter.count({ where: { ...where, status: "DONE" } }),
prisma.encounter.count({
where: { ...where, status: "DONE", kind: "PROCEDURE" },
}),
prisma.encounter.count({ where: { ...where, status: "PLANNED" } }),
prisma.encounter.findFirst({
where: { ...where, status: "DONE" },
orderBy: { occurredAt: "asc" },
select: { occurredAt: true },
}),
]);
if (done > 0) {
return lane(
"carries",
done,
first ? userDayKey(first.occurredAt, frame.tz) : null,
{ detail: { key: "visits", params: { count: done, procedures } } },
);
}
const visitGap = gap("visitsEmpty", 0, VISITS_PATH);
return planned > 0
? lane("thin", planned, null, {
detail: { key: "visitsPlanned", params: { count: planned } },
gaps: [visitGap],
})
: lane("empty", 0, null, { detail: EMPTY_DETAIL, gaps: [visitGap] });
}
async function labsLane(frame: Frame) {
const rows = await prisma.labResult.findMany({
where: { userId: frame.userId, deletedAt: null },
select: { takenAt: true },
orderBy: { takenAt: "asc" },
});
if (rows.length === 0) {
return lane("empty", 0, null, {
detail: EMPTY_DETAIL,
gaps: [gap("labsEmpty", 0, "/labs")],
});
}
const days = new Set(rows.map((r) => userDayKey(r.takenAt, frame.tz)));
return lane("carries", days.size, userDayKey(rows[0].takenAt, frame.tz), {
detail: { key: "labs", params: { count: days.size } },
});
}
async function documentsLane(frame: Frame) {
const [dated, undated] = await Promise.all([
prisma.inboundDocument.findMany({
where: {
userId: frame.userId,
deletedAt: null,
OR: [{ reportDate: { not: null } }, { documentDate: { not: null } }],
},
select: { reportDate: true, documentDate: true },
}),
prisma.inboundDocument.count({
where: {
userId: frame.userId,
deletedAt: null,
reportDate: null,
documentDate: null,
},
}),
]);
const gaps =
undated > 0 ? [gap("documentsUndated", undated, "/documents")] : [];
if (dated.length > 0) {
const since = dated
.map((d) => dateOnlyKey((d.reportDate ?? d.documentDate) as Date))
.sort()[0];
return lane("carries", dated.length, since, {
detail: { key: "documents", params: { count: dated.length } },
gaps,
});
}
return lane(undated > 0 ? "thin" : "empty", undated, null, {
detail: EMPTY_DETAIL,
gaps,
});
}
async function lifeLane(frame: Frame) {
// Owner-only: `laneVisible` leaves this lane out for every delegate.
const where = { userId: frame.userId, deletedAt: null };
const [count, first] = await Promise.all([
prisma.lifeEvent.count({ where }),
prisma.lifeEvent.findFirst({
where,
orderBy: { startDate: "asc" },
select: { startDate: true },
}),
]);
return count > 0
? lane("carries", count, first?.startDate ?? null, {
detail: { key: "life", params: { count } },
})
: lane("empty", 0, null, {
gaps: [gap("lifeEventsEmpty", 0, "/timeline?add=lifeEvent")],
});
}
export async function loadTimelineReadiness(args: {
recordId: string;
tz: string;
access: TimelineAccess;
now?: Date;
}): Promise<TimelineReadinessResponse> {
const { access, tz } = args;
const today = userDayKey(args.now ?? new Date(), tz);
const frame: Frame = {
userId: args.recordId,
tz,
today,
windowStart: startOfLocalDayKey(
shiftDateKey(today, -7 * READINESS_WEEKS + 1),
tz,
),
windowEnd: startOfLocalDayKey(shiftDateKey(today, 1), tz),
};
const valueTypes = access.domainVisible("measurements")
? CORE_VALUE_TYPES.filter((t) => measurementTypeVisible(t, access.modules))
: [];
const readers: Array<[TimelineReadinessKey, () => Promise<LaneResult>]> = [
["values", () => valuesLane(frame, valueTypes)],
];
const laneReaders: Record<
Exclude<TimelineReadinessKey, "values" | "mood" | "environment">,
() => Promise<LaneResult>
> = {
life: () => lifeLane(frame),
illness: () => illnessLane(frame),
allergies: () => allergiesLane(frame),
medications: () => medicationsLane(frame),
vaccinations: () => vaccinationsLane(frame),
visits: () => visitsLane(frame),
labs: () => labsLane(frame),
documents: () => documentsLane(frame),
cycle: () => cycleLane(frame),
};
for (const [key, read] of Object.entries(laneReaders) as Array<
[keyof typeof laneReaders, () => Promise<LaneResult>]
>) {
if (laneVisible(key, access)) readers.push([key, read]);
}
if (access.modules.mood !== false && access.domainVisible("mind")) {
readers.push(["mood", () => moodLane(frame)]);
}
// The environment rows have no delegable route: the owner's lane only.
if (access.modules.environment !== false && access.owner) {
readers.push(["environment", () => environmentLane(frame)]);
}
const results = await Promise.all(readers.map(([, read]) => read()));
const lanes = readers.map(([key], i) => {
const { since: _since, ...rest } = results[i];
return { key, ...rest };
});
const carrying = readers
.map(([key], i) => ({ key, result: results[i] }))
.filter(({ result }) => result.status === "carries");
const verdict =
carrying.length >= 3 && carrying.some(({ key }) => key === "values")
? "carries"
: "thin";
const sinces = carrying
.map(({ result }) => result.since)
.filter((d): d is string => d !== null)
.sort();
return { verdict, since: sinces[0] ?? null, lanes };
}