Merge the final display polish into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 17:25:35 +02:00
commit dcfec1f35d
144 files changed
+4866 -2817

No files matched your search

@@ -130,7 +130,7 @@ describe("deriveClarifyingQuestions — included sections", () => {
);
expect(out.questions).toEqual([
"Want to tell the Coach a bit about yourself — routines, goals, context?",
"Want to tell the Coach a bit about yourself: routines, goals, context?",
]);
expect(providerMocks.hasAnyConfiguredProvider).not.toHaveBeenCalled();
expect(buildCoachSnapshot).not.toHaveBeenCalled();
@@ -214,7 +214,7 @@ describe("buildFallbackQuestions", () => {
const hints = buildFallbackQuestions(emptyCtx, "en", ["ABOUT_ME"]);
expect(hints).toEqual([
"Want to tell the Coach a bit about yourself — routines, goals, context?",
"Want to tell the Coach a bit about yourself: routines, goals, context?",
]);
});
@@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({
// `sourcePriorityJson` to build the rank ladders. `null` here →
// default ladders.
user: { findUnique: vi.fn() },
measurement: { findFirst: vi.fn() },
measurement: { findFirst: vi.fn(), findMany: vi.fn(async () => []) },
// v1.4.36 — slim slice reads DAY buckets from `measurement_rollups`
// on the happy path. The freshness watermark inside
// `ensureUserRollupsFresh` also pokes `measurementRollup.findFirst`;
+66 -2
View File
@@ -61,11 +61,13 @@ import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
HOURLY_MEAN_DAY_TYPES,
usesHourlyMeanDay,
windowWeighting,
} from "@/lib/measurements/day-statistic";
import {
SESSION_DAY_FRAME,
dayValue,
dayWeightedRows,
dayWeightedRowsSql,
foldMeanSql,
@@ -104,6 +106,9 @@ import {
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
import { canonicalRowsOfDay } from "@/lib/mood/linked-context";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -333,7 +338,10 @@ export async function computeSummariesSlice(
// brand-new-type case stays correct.
const coverage = precomputedCoverage ?? (await probeRollupCoverage(userId));
if (isFullyCovered(coverage)) {
return withSleepNightTotals(userId, await computeFromRollups(userId));
return withLatestDayValues(
userId,
await withSleepNightTotals(userId, await computeFromRollups(userId)),
);
}
// v1.4.38.7 — annotate the live fallback with the per-type
// coverage map so the operator can see WHICH type stranded the
@@ -355,7 +363,63 @@ export async function computeSummariesSlice(
},
},
});
return withSleepNightTotals(userId, await computeFromLiveAggregate(userId));
return withLatestDayValues(
userId,
await withSleepNightTotals(userId, await computeFromLiveAggregate(userId)),
);
}
/**
* `latest` of an hourly-mean type (pulse) is the value of its latest local
* day, not its latest single reading.
*
* The dashboard's pulse tile led with the last reading a watch happened to
* take, while the day view showed the day's pulse as the mean of its hours'
* means (`day-mean.ts`). Both say "Pulse"; both now mean the same statistic:
* the latest day's value, read through the same canonical-source pick and
* the same day-mean helper the day view uses, in the person's own zone.
*/
async function withLatestDayValues(
userId: string,
slice: SummariesSlice,
): Promise<SummariesSlice> {
const types = [...HOURLY_MEAN_DAY_TYPES].filter(
(type) =>
slice.summaries[type]?.latest != null && slice.lastSeenByType[type],
);
if (types.length === 0) return slice;
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
await Promise.all(
types.map(async (type) => {
const seen = slice.lastSeenByType[type]!;
const day = userDayKey(new Date(seen.lastSeenAt), tz);
const { dayStart, dayEnd } = localDayWindow(day, tz);
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: dayStart, lt: dayEnd },
},
select: {
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
deviceType: true,
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
});
const canonical = canonicalRowsOfDay(rows, type, day, tz, priorityJson);
const value = dayValue(type, canonical, tz);
if (value !== null) slice.summaries[type].latest = value;
}),
);
return slice;
}
/**
+6 -6
View File
@@ -818,7 +818,7 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
t,
);
expect(checkin(d)?.body).toBe(
"It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
"It's been about a week since you set this plan. Keep it, adjust it, or let it go. No pressure either way.",
);
});
@@ -1293,7 +1293,7 @@ describe("buildDailyDigest — ecg_new_recording (S10)", () => {
);
const item = ecgItem(d);
expect(item?.body).toBe(
"Your device recorded a new ECG — it's ready to view.",
"Your device recorded a new ECG. It's ready to view.",
);
});
@@ -1469,7 +1469,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
);
const visits = d.worthALook.filter((i) => i.kind === "upcoming_visit");
expect(visits).toHaveLength(1);
expect(visits[0].body).toBe("Dr. Weiss, Dentist — today");
expect(visits[0].body).toBe("Dr. Weiss, Dentist today");
});
it("says tomorrow and the day after by calendar day", () => {
@@ -1479,14 +1479,14 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
);
expect(
tomorrow.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
).toBe("Dr. Weiss — tomorrow");
).toBe("Dr. Weiss tomorrow");
const later = buildDailyDigest(
input({ upcomingVisits: [visit({ dayOffset: 2 })] }),
t,
);
expect(
later.worthALook.find((i) => i.kind === "upcoming_visit")?.body,
).toBe("Dr. Weiss — the day after tomorrow");
).toBe("Dr. Weiss the day after tomorrow");
});
it("shows today's visits and the next day's as two items", () => {
@@ -1504,7 +1504,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
d.worthALook
.filter((i) => i.kind === "upcoming_visit")
.map((i) => i.body),
).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]);
).toEqual(["Dr. Weiss today", "Dentist tomorrow"]);
});
});
+31
View File
@@ -7,7 +7,9 @@ import { describe, expect, it } from "vitest";
import {
clearLastDay,
LAST_DAY_STORAGE_KEY,
readDayOpen,
readLastDay,
writeDayClosed,
writeLastDay,
} from "../last-day";
@@ -73,3 +75,32 @@ describe("last day", () => {
expect(() => clearLastDay(throwing)).not.toThrow();
});
});
describe("a day left open", () => {
it("is open once opened, and stays open until the person folds it", () => {
const storage = memoryStorage();
expect(readDayOpen("u1:own", storage)).toBe(false);
writeLastDay("u1:own", "2026-10-05", storage);
expect(readDayOpen("u1:own", storage)).toBe(true);
writeDayClosed("u1:own", storage);
expect(readDayOpen("u1:own", storage)).toBe(false);
// The day itself is kept for the edge.
expect(readLastDay("u1:own", storage)).toBe("2026-10-05");
// Opening it again opens it again.
writeLastDay("u1:own", "2026-10-05", storage);
expect(readDayOpen("u1:own", storage)).toBe(true);
});
it("is never open for another account, an older value or a refusing store", () => {
const storage = memoryStorage();
writeLastDay("u1:own", "2026-10-05", storage);
expect(readDayOpen("u2:own", storage)).toBe(false);
storage.map.set(
LAST_DAY_STORAGE_KEY,
JSON.stringify({ owner: "u1:own", day: "2026-10-05" }),
);
expect(readDayOpen("u1:own", storage)).toBe(false);
expect(readDayOpen("u1:own", throwing)).toBe(false);
expect(() => writeDayClosed("u1:own", throwing)).not.toThrow();
});
});
+2 -1
View File
@@ -71,6 +71,7 @@ describe("day values", () => {
readings,
new Map([["BLOOD_PRESSURE_SYS", band]]),
true,
"UTC",
);
expect(values.map((v) => [v.type, v.value])).toEqual([
["SLEEP_DURATION", 420],
@@ -80,6 +81,6 @@ describe("day values", () => {
]);
expect(values[1].band).toEqual(band);
expect(values[3].at).toBe("2026-03-29T20:00:00.000Z");
expect(shapeDayValues(readings, new Map(), false)).toHaveLength(3);
expect(shapeDayValues(readings, new Map(), false, "UTC")).toHaveLength(3);
});
});
+53 -8
View File
@@ -10,6 +10,11 @@
* never gets it back; a session end removes it outright
* (`clearCachesForSessionEnd`).
*
* Beside the day it keeps whether the day was left open (`open`): a page
* change drops `?day=` from the URL, and the docked column would fold to its
* edge on every navigation. Open stays open until the person folds or closes
* it; the layer reopens the remembered day on the next page that docks it.
*
* Client-only and free of Zod and server imports, like `contract.ts`.
*/
import type { DateKey } from "@/lib/day/contract";
@@ -24,6 +29,8 @@ export const LAST_DAY_CHANGE_EVENT = "healthlog:day-last";
interface Stored {
owner: string;
day: DateKey;
/** Left open by the person; absent in a value an older build wrote. */
open?: boolean;
}
function safeLocalStorage(): Storage | null {
@@ -35,11 +42,10 @@ function safeLocalStorage(): Storage | null {
}
}
/** The remembered day for `owner`, or null (nothing, someone else's, unreadable). */
export function readLastDay(
function readStored(
owner: string | null,
storage: Pick<Storage, "getItem"> | null = safeLocalStorage(),
): DateKey | null {
storage: Pick<Storage, "getItem"> | null,
): Stored | null {
if (owner === null || !storage) return null;
try {
const raw = storage.getItem(LAST_DAY_STORAGE_KEY);
@@ -54,19 +60,35 @@ export function readLastDay(
) {
return null;
}
return parsed.day;
return { owner, day: parsed.day, open: parsed.open === true };
} catch {
return null;
}
}
/** The remembered day for `owner`, or null (nothing, someone else's, unreadable). */
export function readLastDay(
owner: string | null,
storage: Pick<Storage, "getItem"> | null = safeLocalStorage(),
): DateKey | null {
return readStored(owner, storage)?.day ?? null;
}
/** Whether `owner` left the remembered day open. False when unknown. */
export function readDayOpen(
owner: string | null,
storage: Pick<Storage, "getItem"> | null = safeLocalStorage(),
): boolean {
return readStored(owner, storage)?.open === true;
}
function notify() {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(LAST_DAY_CHANGE_EVENT));
}
}
/** Remember `day` as the last one `owner` opened. Best effort. */
/** Remember `day` as the last one `owner` opened, and as open. Best effort. */
export function writeLastDay(
owner: string,
day: DateKey,
@@ -74,10 +96,33 @@ export function writeLastDay(
): void {
if (!storage) return;
try {
if (readLastDay(owner, storage) === day) return;
const held = readStored(owner, storage);
if (held?.day === day && held.open) return;
storage.setItem(
LAST_DAY_STORAGE_KEY,
JSON.stringify({ owner, day } satisfies Stored),
JSON.stringify({ owner, day, open: true } satisfies Stored),
);
} catch {
return;
}
notify();
}
/**
* The person folded or closed the day: keep the day for the edge, but do not
* reopen it on the next page. Best effort.
*/
export function writeDayClosed(
owner: string,
storage: Pick<Storage, "getItem" | "setItem"> | null = safeLocalStorage(),
): void {
if (!storage) return;
try {
const held = readStored(owner, storage);
if (!held || !held.open) return;
storage.setItem(
LAST_DAY_STORAGE_KEY,
JSON.stringify({ ...held, open: false } satisfies Stored),
);
} catch {
return;
+1 -1
View File
@@ -160,7 +160,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
}),
]);
const values = shapeDayValues(readings, bands, sleepReadable);
const values = shapeDayValues(readings, bands, sleepReadable, tz);
const running = parts.flatMap((p) => p.running).sort(bySince);
const events = parts.flatMap((p) => p.events).sort(byTime);
+131 -91
View File
@@ -126,94 +126,130 @@ function dateKeyOrNull(value: Date | null): string | null {
async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
const { userId, day } = frame;
const dayDate = dayKeyAsUtcMidnight(day);
const [meds, courses, pauses, intakes, doseChanges] = await Promise.all([
prisma.medication.findMany({
where: {
userId,
asNeeded: false,
oneShot: false,
// Without a start date, the day the medication was entered stands in.
OR: [
{ startsOn: { lte: dayDate } },
{ startsOn: null, createdAt: { lt: frame.dayEnd } },
],
AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }],
},
select: {
id: true,
name: true,
dose: true,
active: true,
startsOn: true,
endsOn: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.medicationCourse.findMany({
where: {
userId,
startsOn: { lte: dayDate },
OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }],
},
select: {
id: true,
medicationId: true,
startsOn: true,
endsOn: true,
medication: { select: { name: true } },
},
}),
prisma.medicationPauseEra.findMany({
where: {
userId,
pausedAt: { lt: frame.dayEnd },
OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }],
},
select: {
id: true,
medicationId: true,
pausedAt: true,
resumedAt: true,
medication: { select: { name: true } },
},
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
skipped: false,
takenAt: { gte: frame.dayStart, lt: frame.dayEnd },
...TRACKED_INTAKE_EVENT_WHERE,
},
select: {
id: true,
medicationId: true,
takenAt: true,
doseTaken: true,
medication: { select: { name: true, dose: true } },
},
orderBy: { takenAt: "asc" },
}),
prisma.medicationDoseChange.findMany({
where: {
medication: { userId },
effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
medicationId: true,
effectiveFrom: true,
doseValue: true,
doseUnit: true,
note: true,
noteEncrypted: true,
medication: { select: { name: true } },
},
}),
]);
const [meds, courses, pauses, intakes, doseChanges, dosesInEffect] =
await Promise.all([
prisma.medication.findMany({
where: {
userId,
asNeeded: false,
oneShot: false,
// Without a start date, the day the medication was entered stands in.
OR: [
{ startsOn: { lte: dayDate } },
{ startsOn: null, createdAt: { lt: frame.dayEnd } },
],
AND: [{ OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }] }],
},
select: {
id: true,
name: true,
dose: true,
active: true,
startsOn: true,
endsOn: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.medicationCourse.findMany({
where: {
userId,
startsOn: { lte: dayDate },
OR: [{ endsOn: null }, { endsOn: { gte: dayDate } }],
},
select: {
id: true,
medicationId: true,
startsOn: true,
endsOn: true,
medication: { select: { name: true } },
},
}),
prisma.medicationPauseEra.findMany({
where: {
userId,
pausedAt: { lt: frame.dayEnd },
OR: [{ resumedAt: null }, { resumedAt: { gte: frame.dayStart } }],
},
select: {
id: true,
medicationId: true,
pausedAt: true,
resumedAt: true,
medication: { select: { name: true } },
},
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId,
deletedAt: null,
skipped: false,
takenAt: { gte: frame.dayStart, lt: frame.dayEnd },
...TRACKED_INTAKE_EVENT_WHERE,
},
select: {
id: true,
medicationId: true,
takenAt: true,
doseTaken: true,
medication: { select: { name: true, dose: true } },
},
orderBy: { takenAt: "asc" },
}),
prisma.medicationDoseChange.findMany({
where: {
medication: { userId },
effectiveFrom: { gte: frame.dayStart, lt: frame.dayEnd },
},
select: {
id: true,
medicationId: true,
effectiveFrom: true,
doseValue: true,
doseUnit: true,
note: true,
noteEncrypted: true,
medication: { select: { name: true } },
},
}),
// The dose each medication was taken at that day: its last dose change
// on or before the day.
prisma.medicationDoseChange.findMany({
where: { medication: { userId }, effectiveFrom: { lt: frame.dayEnd } },
select: {
medicationId: true,
doseValue: true,
doseUnit: true,
},
orderBy: { effectiveFrom: "desc" },
distinct: ["medicationId"],
}),
]);
const out: SectionPart = { running: [], events: [] };
const doseThatDay = new Map(
dosesInEffect.map((d) => [d.medicationId, `${d.doseValue} ${d.doseUnit}`]),
);
// One running entry per medication. The medication, its course and its
// pause are three rows in three tables, and listed one by one they read
// as three medications ("Mounjaro 7.5 mg" above "Mounjaro"). The entry is
// the medication under its name and the dose of that day; a pause that
// day makes it the paused entry, a course lends it the course's days.
const runningByMedication = new Map<string, DayRunningItem>();
const takeRunning = (medicationId: string, item: DayRunningItem) => {
const held = runningByMedication.get(medicationId);
if (!held) {
runningByMedication.set(medicationId, item);
return;
}
const rank = (r: DayRunningItem) =>
r.kind === "medicationPause" ? 2 : r.kind === "medicationCourse" ? 1 : 0;
const [keep, other] = rank(item) > rank(held) ? [item, held] : [held, item];
runningByMedication.set(medicationId, {
...keep,
sub: keep.kind === "medicationPause" ? null : (keep.sub ?? other.sub),
});
};
for (const med of meds) {
const since = dateKeyOrNull(med.startsOn);
// Without a start date the entry's own date is the earliest the record
@@ -227,13 +263,14 @@ async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
(med.active ? null : userDayKey(med.updatedAt, frame.tz));
if (shownFrom > day || (until !== null && until < day)) continue;
const href = `/medications/${med.id}`;
out.running.push(
takeRunning(
med.id,
running(frame, {
kind: "medication",
section: "medications",
id: med.id,
title: med.name,
sub: med.dose,
sub: doseThatDay.get(med.id) ?? med.dose,
since,
until,
href,
@@ -270,13 +307,14 @@ async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
const since = dateOnlyKey(course.startsOn);
const until = dateKeyOrNull(course.endsOn);
const href = `/medications/${course.medicationId}`;
out.running.push(
takeRunning(
course.medicationId,
running(frame, {
kind: "medicationCourse",
section: "medications",
id: course.id,
title: course.medication.name,
sub: null,
sub: doseThatDay.get(course.medicationId) ?? null,
since,
until,
href,
@@ -313,7 +351,8 @@ async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
? userDayKey(pause.resumedAt, frame.tz)
: null;
const href = `/medications/${pause.medicationId}`;
out.running.push(
takeRunning(
pause.medicationId,
running(frame, {
kind: "medicationPause",
section: "medications",
@@ -387,6 +426,7 @@ async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
}),
);
}
out.running.push(...runningByMedication.values());
return out;
}
+13 -16
View File
@@ -23,6 +23,7 @@ import { readLocalDailyStats } from "@/lib/day/daily-stats";
import { buildBaselineBand } from "@/lib/insights/derived/baseline";
import { HIGH_FREQUENCY_MEAN_TYPES } from "@/lib/measurements/apple-health-mapping";
import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum";
import { dayValue } from "@/lib/measurements/day-mean";
import { HOURLY_MEAN_DAY_TYPES } from "@/lib/measurements/day-statistic";
import type { DayReadingRow, DayReadings } from "@/lib/mood/linked-context";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
@@ -51,28 +52,22 @@ export function dayValueShape(
return readingCount > MAX_READINGS_PER_TYPE ? "dayValue" : "readings";
}
/** The day value of a type's readings, as `daily-stats.ts` computes it. */
/**
* The day value of a type's readings, as `daily-stats.ts` computes it: a
* day total for a cumulative type, the mean of the local hours' means for an
* hourly-mean type (pulse, through `day-mean.ts`, the statistic every pulse
* reader shares, so the dashboard's latest pulse and this day's pulse are the
* same number), the plain mean otherwise.
*/
export function dayValueOf(
type: MeasurementType,
rows: readonly DayReadingRow[],
tz: string,
): number {
if (isCumulativeDaySumType(type)) {
return rows.reduce((sum, r) => sum + r.value, 0);
}
if (HOURLY_MEAN_DAY_TYPES.has(type)) {
const hours = new Map<number, { total: number; count: number }>();
for (const r of rows) {
const hour = Math.floor(r.measuredAt.getTime() / 3_600_000);
const cell = hours.get(hour) ?? { total: 0, count: 0 };
cell.total += r.value;
cell.count += 1;
hours.set(hour, cell);
}
let sum = 0;
for (const cell of hours.values()) sum += cell.total / cell.count;
return sum / hours.size;
}
return rows.reduce((sum, r) => sum + r.value, 0) / rows.length;
return dayValue(type, rows, tz) ?? 0;
}
/** Round to a precision that keeps a reading readable and a mean honest. */
@@ -88,6 +83,8 @@ export function shapeDayValues(
readings: DayReadings,
bands: ReadonlyMap<MeasurementType, DayValue["band"]>,
includeSleep: boolean,
/** The record's zone: a pulse day is the mean of its local hours. */
tz: string,
): DayValue[] {
const out: DayValue[] = [];
for (const [type, rows] of readings.rowsByType) {
@@ -109,7 +106,7 @@ export function shapeDayValues(
} else {
out.push({
type,
value: tidy(dayValueOf(type, rows)),
value: tidy(dayValueOf(type, rows, tz)),
unit: last.unit,
at: last.measuredAt.toISOString(),
source: last.source,
@@ -0,0 +1,25 @@
/**
* One map from a kind of value to its page, read by the day view's value
* tiles and the timeline's value-line names alike.
*/
import { describe, expect, it } from "vitest";
import { metricPageHref } from "../metric-page";
describe("metricPageHref", () => {
it("leads a value with a page of its own to that page", () => {
expect(metricPageHref("WEIGHT")).toBe("/insights/weight");
expect(metricPageHref("PULSE")).toBe("/insights/pulse");
expect(metricPageHref("SLEEP_DURATION")).toBe("/insights/sleep");
expect(metricPageHref("BODY_FAT")).toMatch(/^\/insights\/body-fat/);
expect(metricPageHref("BLOOD_PRESSURE")).toBe("/insights/blood-pressure");
expect(metricPageHref("BLOOD_PRESSURE_SYS")).toBe(
"/insights/blood-pressure",
);
expect(metricPageHref("MOOD")).toBe("/insights/mood");
});
it("gives a kind without a page no link", () => {
expect(metricPageHref("NOT_A_TYPE")).toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* The page a value's name leads to: one answer for every surface that lists
* values by kind (the day view's value tiles, the timeline's value lines).
*
* A measurement type goes to the insights sub-page that focuses it
* (`subPageSlugForType`); the day view's blood-pressure tile, keyed
* `BLOOD_PRESSURE`, goes to the blood-pressure page; the mood score goes to
* the mood page. A kind without a page of its own has no link (`null`), and
* its name stays plain text: a link to a filtered list would be a different
* promise than "this value's page".
*
* Pure and client-safe.
*/
import { subPageSlugForType } from "@/lib/insights/sub-page-metric";
/** The mood score's key on the timeline (`MOOD_SERIES_KEY` on the server). */
const MOOD_KEY = "MOOD";
export function metricPageHref(key: string): string | null {
if (key === MOOD_KEY) return "/insights/mood";
if (key === "BLOOD_PRESSURE") return "/insights/blood-pressure";
const slug = subPageSlugForType(key);
return slug ? `/insights/${slug}` : null;
}
+1 -1
View File
@@ -160,7 +160,7 @@ interface LinkedRow {
* untouched, which is the picker's own pass-through and is correct for a type
* no two sources compete over.
*/
function canonicalRowsOfDay(
export function canonicalRowsOfDay(
rows: readonly LinkedRow[],
type: MeasurementType,
day: string,
@@ -0,0 +1,87 @@
/**
* The window filter keeps a medication whole: when one of its items touches
* the window, every item of it is sent, because the client reads the
* stretches from its courses and the dose of a piece from the last dose
* change before it, which may lie outside the window.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import type { TimelineItem } from "@/lib/day/contract";
import { keepTouchingGroups } from "../load-timeline";
const base = {
end: null,
open: false,
precision: "DAY" as const,
startKnown: true,
sub: null,
href: null,
};
const entry = (
over: Partial<TimelineItem> & Pick<TimelineItem, "id" | "kind" | "start">,
): TimelineItem =>
({ ...base, label: over.id, group: null, ...over }) as TimelineItem;
describe("keepTouchingGroups", () => {
const items = [
entry({
id: "mj",
group: "mj",
kind: "medication",
start: "2024-01-01",
open: true,
}),
entry({
id: "c1",
group: "mj",
kind: "course",
start: "2024-01-01",
end: "2024-06-30",
}),
entry({
id: "c2",
group: "mj",
kind: "course",
start: "2026-01-01",
open: true,
}),
entry({
id: "d1",
group: "mj",
kind: "doseChange",
start: "2024-01-01",
sub: "2.5 mg",
}),
entry({
id: "old",
group: "old",
kind: "medication",
start: "2020-01-01",
end: "2020-03-01",
}),
entry({ id: "v1", kind: "vaccination", start: "2023-05-01" }),
];
it("sends every item of a medication that touches the window", () => {
const kept = keepTouchingGroups(
items,
"2026-07-01",
"2026-10-09",
"2026-10-09",
);
expect(kept.map((i) => i.id).sort()).toEqual(["c1", "c2", "d1", "mj"]);
});
it("leaves out a group and a lone item that do not touch it", () => {
const kept = keepTouchingGroups(
items,
"2026-07-01",
"2026-10-09",
"2026-10-09",
);
expect(kept.some((i) => i.id === "old" || i.id === "v1")).toBe(false);
});
});
+26 -3
View File
@@ -172,6 +172,29 @@ export function overlaps(
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;
@@ -216,9 +239,9 @@ export async function loadTimeline(args: {
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));
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);
});