mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge the final display polish into release/v1.42.0
This commit is contained in:
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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user