Merge remote-tracking branch 'origin/release/v1.47.2' into takeover/1370

# Conflicts:
#	CHANGELOG.md
#	src/lib/module-list-prefs.ts
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 00:04:51 +01:00
commit a486c3c8a3
148 files changed
+4668 -347

No files matched your search

@@ -131,10 +131,12 @@ const EXPECTED_TYPES = [
"ACUTE_TRAINING_LOAD",
// ── resting energy (#1340) ──
"BASAL_ENERGY_BURNED",
// ── blood ketones (#1367) ──
"BLOOD_KETONES",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 91 canonical measurement types", () => {
it("exposes the 92 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -68,6 +68,7 @@ describe("parseModuleListPrefs", () => {
order: ["a", "b"],
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
});
});
@@ -204,6 +205,85 @@ describe("runSetModuleListPrefs", () => {
});
});
describe("runSetModuleListPrefs category order", () => {
const t = (key: string) => key;
const overview = (keys: string[]) => ({
markers: [],
grouped: true,
categories: keys.map((key) => ({ key, label: key })),
});
it("moves the cached overview's categories at once and refetches it", async () => {
const queryClient = new QueryClient();
const cell = queryKeys.labResultsOverview("en");
queryClient.setQueryData(cell, overview(["lipids", "thyroid", "other"]));
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
let cachedDuringWrite: unknown;
vi.mocked(apiPut).mockImplementation(async () => {
cachedDuringWrite = queryClient.getQueryData(cell);
return resolveModuleListLayout({
labs: { categoryOrder: ["thyroid", "other", "lipids"] },
});
});
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "other", "lipids"] },
queryClient,
t,
});
expect(apiPut).toHaveBeenCalledWith("/api/settings/module-list-layout", {
version: 1,
list: "labs",
categoryOrder: ["thyroid", "other", "lipids"],
});
expect(
(cachedDuringWrite as ReturnType<typeof overview>).categories.map(
(c) => c.key,
),
).toEqual(["thyroid", "other", "lipids"]);
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
// An order move is its own feedback, like the marker order.
expect(toastWrittenOutcome).not.toHaveBeenCalled();
});
it("refetches the overview after a failed write too, so the server order returns", async () => {
const queryClient = new QueryClient();
queryClient.setQueryData(
queryKeys.labResultsOverview("en"),
overview(["lipids", "thyroid"]),
);
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockRejectedValue(new Error("offline"));
await runSetModuleListPrefs({
list: "labs",
patch: { categoryOrder: ["thyroid", "lipids"] },
queryClient,
t,
});
expect(showErrorToast).toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith({
queryKey: queryKeys.labResultsOverview(),
});
});
it("leaves the overview alone for any other field", async () => {
const queryClient = new QueryClient();
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
vi.mocked(apiPut).mockResolvedValue(resolveModuleListLayout(null));
await runSetModuleListPrefs({
list: "labs",
patch: { order: ["m1"] },
queryClient,
t,
});
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("applyOrder", () => {
const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
const idOf = (i: { id: string }) => i.id;
+145
View File
@@ -0,0 +1,145 @@
/**
* #1350 — how a forecast period is drawn in each style. Dates only: the
* projection is handed in, so every expected day below is the arithmetic of
* one fixed cycle and nothing else.
*/
import { describe, expect, it } from "vitest";
import type { ProjectedCycle } from "@/lib/cycle/projection";
import { eventUid } from "@/lib/cycle/ics";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "../events";
/** Most likely start 17 Nov, five bleeding days, ±2 days. */
const CYCLE: ProjectedCycle = {
k: 1,
start: "2026-11-17",
periodLength: 5,
bandStart: "2026-11-15",
bandEnd: "2026-11-19",
};
function events(
forecastStyle: FeedForecastStyle,
projected: ProjectedCycle[] = [CYCLE],
titleMode: FeedTitleMode = "DISCREET",
locale: "en" | "de" = "en",
) {
return buildFeedCalendar({
feedId: "feed-1",
titleMode,
forecastStyle,
projected,
history: [],
fertile: null,
translator: getServerTranslator(locale),
}).events;
}
/** start .. endExclusive, the pair DTSTART / DTEND carry. */
const span = (e: { start: string; endExclusive: string }) => [
e.start,
e.endExclusive,
];
describe("forecast styles", () => {
it("BAND draws one event from the earliest start to the latest period's end", () => {
const out = events("BAND");
expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("EXACT draws only the calculated period", () => {
const out = events("EXACT");
expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("MARGIN draws the calculated period with the possible days on each side", () => {
const out = events("MARGIN");
expect(out.map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
expect(out.map((e) => e.summary)).toEqual([
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
DISCREET_MARGIN_SUMMARY,
]);
});
it("MARGIN covers exactly the days BAND covers, without overlap", () => {
const [band] = events("BAND");
const margin = events("MARGIN");
expect(margin[0].start).toBe(band.start);
expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive);
for (let i = 1; i < margin.length; i++) {
expect(margin[i].start).toBe(margin[i - 1].endExclusive);
}
});
it("MARGIN draws no side event where the band is empty", () => {
const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start };
expect(events("MARGIN", [exact]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
]);
const lateOnly = { ...CYCLE, bandStart: CYCLE.start };
expect(events("MARGIN", [lateOnly]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start };
expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
]);
});
it("keeps the period's UID across styles and gives each margin its own", () => {
const main = eventUid("feed-1", "pred:1");
expect(events("BAND")[0].uid).toBe(main);
expect(events("EXACT")[0].uid).toBe(main);
expect(events("MARGIN").map((e) => e.uid)).toEqual([
eventUid("feed-1", "pred:1:before"),
main,
eventUid("feed-1", "pred:1:after"),
]);
});
it("draws every forecast cycle it is given", () => {
const next = {
k: 2,
start: "2026-12-15",
periodLength: 5,
bandStart: "2026-12-12",
bandEnd: "2026-12-18",
};
expect(events("EXACT", [CYCLE, next])).toHaveLength(2);
expect(events("MARGIN", [CYCLE, next])).toHaveLength(6);
expect(events("BAND", [CYCLE, next])).toHaveLength(2);
});
it("names the possible days in the record's language when descriptive", () => {
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary),
).toEqual(["Period possible", "Period (estimate)", "Period possible"]);
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary),
).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]);
});
it("describes every forecast event with its band", () => {
for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) {
expect(e.description).toBe("Most likely start Nov 17, ±2 days");
}
for (const e of events("EXACT")) expect(e.description).toBe("±2 days");
});
});
+1
View File
@@ -48,6 +48,7 @@ export const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "BAND" as const,
};
export const SUBJECT = {
@@ -54,6 +54,7 @@ const FEED_ROW = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET",
forecastStyle: "BAND",
};
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
+7 -1
View File
@@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { icsContentHash, type IcsCalendar } from "../ics";
import { buildFeedCalendar, type FeedTitleMode } from "./events";
import {
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "./events";
import { historyPeriods, historyWindowStart } from "./history";
/** The feed options the body depends on. */
@@ -40,6 +44,7 @@ export interface FeedOptions {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
/** The record the feed serves, as far as building it needs. */
@@ -147,6 +152,7 @@ export async function buildCycleFeed(
const calendar = buildFeedCalendar({
feedId: feed.id,
titleMode: feed.titleMode,
forecastStyle: feed.forecastStyle,
projected: projection.cycles,
history,
fertile,
+85 -19
View File
@@ -11,6 +11,13 @@
* DISCREET (the default) titles every event with a dot and names the calendar
* "HealthLog", so a glance at a shared screen or a lock-screen notification
* says nothing. DESCRIPTIVE names the period, in the record's own language.
*
* A forecast period is drawn in one of three styles. EXACT is the period as
* calculated: the most likely first day for the predicted number of bleeding
* days. MARGIN is the same event, plus the days the period may start earlier
* or run on later as their own "possible" events, so the calculated days stay
* readable as such. BAND is one event over every day the period may fall on,
* from the earliest likely start to the last bleeding day of the latest.
*/
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import type { ProjectedCycle } from "@/lib/cycle/projection";
@@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
/** The discreet title: one dot, no word. */
export const DISCREET_SUMMARY = "●";
/** The discreet title of a MARGIN style's possible days: an open dot. */
export const DISCREET_MARGIN_SUMMARY = "○";
/** The discreet calendar name. */
export const DISCREET_CALENDAR_NAME = "HealthLog";
@@ -39,6 +49,7 @@ export interface HistoryPeriod {
export interface FeedEventInput {
feedId: string;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
projected: readonly ProjectedCycle[];
history: readonly HistoryPeriod[];
/** The next fertile window, already gated; null when not shown. */
@@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string {
);
}
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
/** The forecast's description: the likely start and the band, or the band. */
function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string {
const { translator } = input;
const band = bandText(dayDiff(p.bandEnd, p.start), translator);
return input.titleMode === "DESCRIPTIVE"
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band,
})
: band;
}
/**
* One forecast cycle's events. DTEND is exclusive throughout, so the period
* itself ends the day after its last bleeding day.
*/
function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] {
const { translator } = input;
const band = dayDiff(p.bandEnd, p.start);
const descriptive = input.titleMode === "DESCRIPTIVE";
return {
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start through
// the last bleeding day of the latest likely start. DTEND is exclusive.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY,
description: descriptive
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band: bandText(band, translator),
})
: bandText(band, translator),
};
const description = forecastDescription(p, input);
const periodSummary = descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY;
if (input.forecastStyle === "BAND") {
return [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start
// through the last bleeding day of the latest likely start.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: periodSummary,
description,
},
];
}
const periodEnd = addDays(p.start, p.periodLength);
const events: IcsEvent[] = [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
start: p.start,
endExclusive: periodEnd,
summary: periodSummary,
description,
},
];
if (input.forecastStyle === "EXACT") return events;
const marginSummary = descriptive
? translator.t("cycle.feed.ics.periodPossible")
: DISCREET_MARGIN_SUMMARY;
// The days before the likely start it may begin on, and the days after the
// likely period it may still run on. A side with no band has no event.
if (dayDiff(p.start, p.bandStart) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:before`),
start: p.bandStart,
endExclusive: p.start,
summary: marginSummary,
description,
});
}
const bandEndExclusive = addDays(p.bandEnd, p.periodLength);
if (dayDiff(bandEndExclusive, periodEnd) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:after`),
start: periodEnd,
endExclusive: bandEndExclusive,
summary: marginSummary,
description,
});
}
return events;
}
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
@@ -116,7 +182,7 @@ function fertileEvent(
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
const events: IcsEvent[] = [
...input.history.map((h) => historyEvent(h, input)),
...input.projected.map((p) => forecastEvent(p, input)),
...input.projected.flatMap((p) => forecastEvents(p, input)),
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
return {
+1
View File
@@ -116,6 +116,7 @@ const FEED_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
/**
+5 -1
View File
@@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection";
import { buildCycleFeed, type FeedSubject } from "./build";
import { fetcherFamily, type FetcherFamily } from "./fetcher";
import type { FeedTitleMode } from "./events";
import type { FeedForecastStyle, FeedTitleMode } from "./events";
export const FEED_SUMMARY_SELECT = {
id: true,
@@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
export interface FeedSummaryRow {
@@ -36,6 +37,7 @@ export interface FeedSummaryRow {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedSummary {
@@ -49,6 +51,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
historyCycles: row.historyCycles,
includeFertile: row.includeFertile,
titleMode: row.titleMode,
forecastStyle: row.forecastStyle,
};
}
+4 -4
View File
@@ -1406,7 +1406,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const d = buildDailyDigest(
input({
...crowd,
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
}),
t,
);
@@ -1416,7 +1416,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
expect(d.worthALook[0].kind).toBe("dose_window");
});
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
it("does not pin a short-cycle reminder: it comes round within days", () => {
const d = buildDailyDigest(
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
t,
@@ -1436,7 +1436,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const d = buildDailyDigest(
input({
...crowd,
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
upcomingVisits: [visit()],
}),
t,
@@ -1546,7 +1546,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
it("leaves the due check-ups item on the checkups list", () => {
const d = buildDailyDigest(
input({
preventiveDue: [{ label: "Skin check", staysDue: true }],
preventiveDue: [{ label: "Skin check", longCycle: true }],
upcomingVisits: [visit()],
}),
t,
+7 -7
View File
@@ -166,11 +166,11 @@ export interface DailyDigestPreventiveDue {
/** Display label, already resolved (a Coach cadence key is translated). */
label: string;
/**
* Whether this reminder stays due after it is sent (a cycle longer than a
* week, or none). Only such a reminder earns the check-up item a place
* under the rail cap; a short-cycle rhythm rolls on by itself.
* Whether this reminder is on a cycle longer than a week, or has none
* (`hasLongCycle`). Only such a reminder earns the check-up item a place
* under the rail cap; a short-cycle rhythm comes round again within days.
*/
staysDue?: boolean;
longCycle?: boolean;
}
/**
@@ -743,8 +743,8 @@ function buildUpcomingVisitItems(
/**
* The bounded rail: at most `max` items, in priority order, with the pinned
* items guaranteed a place. A pinned item is one a person can miss for good if
* the cap drops it today: a reminder that stays due and is due or overdue
* (its notification has already gone out, and it will not roll on by itself)
* the cap drops it today: a reminder on a long cycle that is due or overdue
* (its notification has already gone out, and the next one is a week away)
* and a visit booked for today (the day ends). The rest fill
* the remaining slots in order, and the result keeps the priority order, so an
* overdue dose still leads.
@@ -1207,7 +1207,7 @@ export function buildDailyDigest(
// `capWithPinned`); they still sit below a dose in the displayed order.
const pinned = new Set<PriorityItem>(
[
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
input.preventiveDue.some((due) => due.longCycle) ? preventive : null,
visits.today,
].filter((item): item is PriorityItem => item !== null),
);
+12 -12
View File
@@ -28,7 +28,7 @@ import {
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
import { hasLongCycle } from "@/lib/measurement-reminders/holds-open";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { isArrivalKind } from "@/lib/arrivals/types";
@@ -592,9 +592,9 @@ export async function loadDailyDigest(
//
// Read up to the end of the person's local day; the filter below keeps
// what is due now, plus what is due later today when it is a reminder
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
// reminder keeps its due date after it is sent, so the overdue ones
// stay here until they are satisfied, skipped or snoozed.
// on a long cycle (see `hasLongCycle`). A reminder that stays due after
// it is sent (`holdsOpenAfterReminder`) keeps its due date, so the
// overdue ones stay here until they are satisfied, skipped or snoozed.
where: {
userId: user.id,
enabled: true,
@@ -896,21 +896,21 @@ export async function loadDailyDigest(
// the checkups page and the dashboard card translate it, and so does this
// line. A free-text label (every other row) is the person's own words.
//
// A reminder that stays due after it is sent (a cycle longer than a week,
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
// whole due day and keeps its place under the cap. A short-cycle one (a
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
// time has come, is not pinned, and its next reading moves it on.
// A reminder on a cycle longer than a week (a fortnightly questionnaire, a
// yearly check-up) is on the rail for its whole due day and keeps its place
// under the cap. A short-cycle one (a daily weigh-in, a twice-daily course)
// is a rhythm: it shows only once its time has come and is not pinned; a
// missed rolling one then stays here, overdue, until its reading lands.
const preventiveDue: DailyDigestPreventiveDue[] = [];
for (const row of dueReminders) {
if (row.nextDueAt === null) continue;
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
const longCycle = hasLongCycle(row, user.timezone, row.nextDueAt);
if (!longCycle && row.nextDueAt.getTime() > now.getTime()) continue;
let label = row.label;
if (row.origin === "COACH") {
label = translateOrNull(t, row.label) ?? label;
}
preventiveDue.push({ label, staysDue });
preventiveDue.push({ label, longCycle });
}
// Group the two figures for the reader. `Intl.NumberFormat` is the only
+1
View File
@@ -341,6 +341,7 @@ export const CHART_OVERLAY_KEYS = [
"scoreSleep",
"scoreStress",
"scoreStrain",
"bloodKetones", // #1367 — the chart on the ketones page.
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
+15
View File
@@ -29,6 +29,7 @@ import {
import { DAY_SCORE_MEASUREMENT_TYPES, readDayScores } from "@/lib/day/scores";
import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections";
import { readDayBands, shapeDayValues } from "@/lib/day/values";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import {
resolveDayReadings,
type DayReadings,
@@ -153,6 +154,19 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
]);
const presentTypes = [...readings.rowsByType.keys()];
// The index reveals the glucose it was built from, so it needs both
// reading types visible, not only the ketones the day holds.
const gkiReadable =
readings.rowsByType.has("BLOOD_KETONES") &&
typeVisible("BLOOD_KETONES") &&
typeVisible("BLOOD_GLUCOSE");
const glucoseKetoneIndex = gkiReadable
? await readGlucoseKetoneIndex({
userId: recordId,
from: dayStart,
to: dayEnd,
})
: [];
const [bands, notable] =
presentTypes.length === 0
? [new Map(), []]
@@ -194,6 +208,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
counts: { values: values.length, entries: events.length },
running,
values,
...(glucoseKetoneIndex.length > 0 ? { glucoseKetoneIndex } : {}),
events,
notable: notable.map(({ kind, type, params }) => ({ kind, type, params })),
scores,
+24
View File
@@ -117,6 +117,24 @@ const dayValue = z
})
.meta({ id: "DayValue" });
/**
* #1367 — one glucose-ketone index of the day, derived from a ketone reading
* and the glucose reading paired with it; never stored.
*/
const dayGlucoseKetoneIndex = z
.object({
at: instant.describe("The ketone reading's instant."),
value: z.number().describe("The index, to one decimal."),
ketoneId: z.string().describe("The ketone reading's id."),
glucoseId: z.string().describe("The paired glucose reading's id."),
glucoseAt: instant.describe("The paired glucose reading's instant."),
ketonesMmol: z.number().describe("Beta-hydroxybutyrate, mmol/L."),
glucoseMmol: z
.number()
.describe("The paired glucose in mmol/L, one decimal."),
})
.meta({ id: "DayGlucoseKetoneIndex" });
const dayEvent = z
.object({
at: instant
@@ -232,6 +250,12 @@ export const dayResponseSchema = z
}),
running: z.array(dayRunningItem),
values: z.array(dayValue),
glucoseKetoneIndex: z
.array(dayGlucoseKetoneIndex)
.optional()
.describe(
"#1367 — the day's glucose-ketone index, oldest first: glucose (mmol/L) over ketones (mmol/L) for each ketone reading above zero with a glucose reading at the same instant or within 15 minutes either side. Computed at read time; show it as given. Absent when the day has none, when either reading type is hidden from the caller, and on older servers.",
),
events: z.array(dayEvent),
notable: z.array(dayNotable),
scores: z
@@ -186,8 +186,9 @@ function groupSubHeadRow(label: string): VitalTableRow {
}
/**
* Per-context glucose rows. Values are stored canonically in mg/dL and print
* in the owner's unit, in the precision their readings carry.
* The glucose group's own rows: one per meal context, stored canonically in
* mg/dL and printed in the owner's unit in the precision their readings
* carry, then the glucose-ketone index (#1367), a ratio without a unit.
*/
function glucoseContextTableRows(
context: DoctorReportPdfRenderContext,
@@ -209,6 +210,17 @@ function glucoseContextTableRows(
String(s.count),
]);
}
const gki = data.glucoseKetoneIndex;
if (gki) {
rows.push([
t("measurements.glucoseKetoneIndex"),
num(gki.latest, 1),
num(gki.avg, 1),
num(gki.min, 1),
num(gki.max, 1),
String(gki.count),
]);
}
return rows;
}
+7
View File
@@ -118,6 +118,13 @@ export interface DoctorReportData {
* `stillLearning` when the period is too thin to assert clinically.
*/
glucoseClinical: GlucoseClinicalMetrics;
/**
* #1367 — the glucose-ketone index over the period (count, mean, range,
* latest), derived at read time from each ketone reading and the glucose
* reading paired with it. Null when there is none or when either reading
* type is withheld. Optional so older fixtures still typecheck.
*/
glucoseKetoneIndex?: DoctorReportStats | null;
bmi: number | null;
/** Keyed by medication id; see `DoctorReportCompliance`. */
compliance: Record<string, DoctorReportCompliance>;
+4 -3
View File
@@ -67,6 +67,7 @@ import {
import { loadDenseMeasurementBuckets } from "./dense-buckets";
import { summariseDenseBuckets } from "./measurement-series";
import { emptyGlucoseClinical } from "./glucose-panel";
import { readReportGki } from "./glucose-ketone-index";
import { buildAdministrationLedger, buildGlp1Block } from "./medications";
import { loadReportWorkouts } from "./workouts";
import { loadReportMedicationEffects } from "./medication-effects";
@@ -249,9 +250,8 @@ export async function collectDoctorReportData(
...(rawExcluded.length > 0 ? { type: { notIn: rawExcluded } } : {}),
},
orderBy: { measuredAt: "asc" },
// Narrow select: the collector reads exactly these seven fields.
// The full-width read pulled every column — including the encrypted
// notes — across up to 730 days of rows.
// Narrow select: the full-width read pulled every column, encrypted
// notes included, across up to 730 days of rows.
select: {
type: true,
value: true,
@@ -739,6 +739,7 @@ export async function collectDoctorReportData(
glucoseStats,
glucoseRanges,
glucoseClinical,
glucoseKetoneIndex: await readReportGki(gate, stats, userId, start, end),
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: gate.admits("BODY_MASS_INDEX") ? bmi : null,
compliance: gate.admits("MEDICATION_COMPLIANCE") ? compliance : {},
@@ -0,0 +1,45 @@
/**
* The glucose-ketone index in the doctor report (#1367): count, mean, range
* and latest over the report window, read by the one pairing rule
* (`read-glucose-ketone-index.ts`) so the report carries the numbers the
* ketones page shows.
*
* Both leaves must admit it. The index is glucose over ketones, so beside the
* ketone series it gives the glucose back exactly: a report that withholds
* glucose withholds the index too.
*/
import type { DoctorReportStats } from "@/lib/doctor-report-types";
import { summariseGlucoseKetoneIndex } from "@/lib/measurements/glucose-ketone-index";
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import type { ReportGate } from "./selection-gate";
export async function readReportGki(
gate: ReportGate,
/** The report's own stats: no ketone row in the window, no read. */
stats: Readonly<Record<string, DoctorReportStats>>,
userId: string,
/** The report window; `end` is inclusive, as the measurement read has it. */
start: Date,
end: Date,
): Promise<DoctorReportStats | null> {
if (!gate.admits("BLOOD_KETONES") || !gate.admits("BLOOD_GLUCOSE")) {
return null;
}
if (!stats.BLOOD_KETONES?.count) return null;
const points = await readGlucoseKetoneIndex({
userId,
from: start,
to: new Date(end.getTime() + 1),
});
const summary = summariseGlucoseKetoneIndex(points);
if (!summary) return null;
return {
avg: summary.mean,
min: summary.min,
max: summary.max,
count: summary.count,
latest: summary.latest,
latestAt: points[points.length - 1]!.at,
};
}
@@ -193,6 +193,39 @@ describe("runInboundExtraction — stated-status-only mapping", () => {
expect(result.facts.map((f) => f.needsReview)).toEqual([true, false]);
});
it('drops a lab\'s "(calc)" note from the unit, so two reports land in one', async () => {
const provider = fakeProvider(
JSON.stringify({
facts: [
{
type: "OBSERVATION",
label: "Globulin",
value: 2.6,
unit: "g/dL (calc)",
confidence: 0.95,
sourceText: "Globulin 2.6 g/dL (calc)",
},
{
type: "OBSERVATION",
label: "A/G Ratio",
value: 1.7,
unit: "(calc)",
confidence: 0.95,
sourceText: "A/G Ratio 1.7 (calc)",
},
],
}),
);
const result = await runInboundExtraction(oneRead(provider));
expect(
result.facts.map((f) => (f.data as ObservationFactData).unit),
).toEqual(["g/dL", null]);
// A unit that was only the note goes to review: no unit is a choice a
// person makes, not one a read makes for them.
expect(result.facts.map((f) => f.needsReview)).toEqual([false, true]);
});
it("stores the document's real span, not the model's echo of it", async () => {
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
// rendering of it. The stored provenance must be the document's characters.
+21 -3
View File
@@ -58,6 +58,7 @@ import {
type OcrReadSurface,
} from "@/lib/documents/ocr-read";
import { isDimensionlessUnit } from "@/lib/labs/dimensionless";
import { withoutLabUnitAnnotation } from "@/lib/labs/unit-normalise";
import { annotate } from "@/lib/logging/context";
import {
INBOUND_CONFIDENCE_FLOOR,
@@ -168,7 +169,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
codeSystem: system === "LOINC" ? system : null,
value: hasNumeric ? raw.value : null,
valueText: hasNumeric ? null : raw.valueText,
unit: raw.unit,
unit: observationUnit(raw.unit),
referenceLow: raw.referenceLow,
referenceHigh: raw.referenceHigh,
referenceText: raw.referenceText,
@@ -189,6 +190,17 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
return data;
}
/**
* The printed unit without a lab's "(calc)" note, so the same marker read
* from two reports, one with the note and one without, lands in one unit.
* A unit that was only the note is no unit.
*/
function observationUnit(unit: string | null): string | null {
if (unit === null) return null;
const bare = withoutLabUnitAnnotation(unit);
return bare === "" ? null : bare;
}
/** A staged fact ready to persist (before it gets an id). */
export interface StagedFactInput {
factType: InboundFactType;
@@ -306,7 +318,7 @@ function factKey(raw: ExtractedFactRaw, readDate: string | null): string {
raw.label.trim().toLowerCase(),
raw.value ?? "",
(raw.valueText ?? "").trim().toLowerCase(),
(raw.unit ?? "").trim().toLowerCase(),
(observationUnit(raw.unit) ?? "").toLowerCase(),
(raw.referenceText ?? "").trim().toLowerCase(),
(raw.dose ?? "").trim().toLowerCase(),
normaliseDate(raw.effectiveDate) ?? readDate ?? "",
@@ -387,9 +399,15 @@ export async function runInboundExtraction(
// A unit of "1" is the user's explicit no-unit choice. A read that
// states it itself goes to review rather than straight to the record,
// so a dropped unit never passes as a dimensionless value.
// So does a unit column that held only a "(calc)" note: it reads as no
// unit, and a person decides whether the value really has none.
needsReview:
confidence < INBOUND_CONFIDENCE_FLOOR ||
(raw.type === "OBSERVATION" && isDimensionlessUnit(raw.unit)),
(raw.type === "OBSERVATION" &&
(isDimensionlessUnit(raw.unit) ||
(raw.unit !== null &&
raw.unit.trim() !== "" &&
observationUnit(raw.unit) === null))),
data,
provenance: {
sourceText: anchor.sourceText,
+26
View File
@@ -196,6 +196,32 @@ describe("measurementSeriesObservation", () => {
expect(o.code.coding?.[0]?.code).toBe(MEASUREMENT_LOINC.WEIGHT.loinc);
});
it("codes blood ketones as beta-hydroxybutyrate in blood (#1367)", () => {
const o = measurementSeriesObservation(
{
id: "k1",
type: "BLOOD_KETONES",
value: 1.4,
valueMin: null,
valueMax: null,
measuredAt: AT,
glucoseContext: null,
},
"mg/dL",
null,
)!;
assertR4Observation(o);
expect(o.category?.[0]?.coding?.[0]?.code).toBe("laboratory");
expect(o.code.coding).toEqual([
{
system: "http://loinc.org",
code: "104816-4",
display: "Beta hydroxybutyrate [Moles/volume] in Blood",
},
]);
expect(o.valueQuantity).toMatchObject({ value: 1.4, unit: "mmol/L" });
});
it("labels RMSSD as its own text-only statistic, never under SDNN's code", () => {
const o = measurementSeriesObservation(
{
+1
View File
@@ -29,6 +29,7 @@ export const ALLOWED_CHART_TOKENS = [
"metric:SLEEP_DURATION",
"metric:ACTIVITY_STEPS",
"metric:BLOOD_GLUCOSE",
"metric:BLOOD_KETONES",
"metric:TOTAL_BODY_WATER",
"metric:BONE_MASS",
"metric:OXYGEN_SATURATION",
+2
View File
@@ -80,6 +80,8 @@ export type InsightMetric =
// `summaries[metric].count > 0` with no new handler branch (the
// metric auto-lights-up the moment the first reading lands).
| "BLOOD_GLUCOSE"
// #1367 — blood ketones, keyed on its MeasurementType like glucose.
| "BLOOD_KETONES"
| "TOTAL_BODY_WATER"
| "BONE_MASS"
// Body fat percentage, keyed on its MeasurementType like the rest of the
+3
View File
@@ -139,6 +139,8 @@ const SUB_PAGE_METRIC = {
daylight: ["TIME_IN_DAYLIGHT"],
// ── metabolic (v1.7.0) ──
"blood-glucose": ["BLOOD_GLUCOSE"],
// #1367 — blood ketones, with the glucose-ketone index beneath the chart.
ketones: ["BLOOD_KETONES"],
"skin-temperature": ["SKIN_TEMPERATURE"],
// v1.10.0 — overnight wrist temperature joins the metabolic cluster.
"wrist-temperature": ["WRIST_TEMPERATURE"],
@@ -243,6 +245,7 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
daylight: "environment",
// metabolic (v1.7.0)
"blood-glucose": "metabolic",
ketones: "metabolic",
"skin-temperature": "metabolic",
// ── v1.10.0 — additive HealthKit signals (WX-A) ──
// activity / mobility cluster
@@ -9,11 +9,12 @@
* logged since the last satisfy advances lastSatisfiedAt + recomputes
* nextDueAt and suppresses the nudge. Free-text reminders never
* auto-resolve.
* - A reminder on a weekly or shorter cycle rolls on: a successful
* dispatch advances nextDueAt past now. One on a longer cycle, or a
* one-shot, does NOT roll on (measurement reminder or free text): it
* stays due until it is satisfied, and a second nudge for the same open
* slot waits a week. Appointments stay one-shot.
* - A calendar rule recurring within the week rolls on: a successful
* dispatch advances nextDueAt past now. A rolling interval of any
* length, a rule on a longer cycle, or a one-shot does NOT roll on
* (measurement reminder or free text): it stays due until it is
* satisfied, and a second nudge for the same open slot waits its
* interval, at most a week. Appointments stay one-shot.
* - clientManaged suppresses the APNs leg only: the tick still
* dispatches, and a reminder nothing delivered stays overdue.
*
@@ -58,6 +59,7 @@ beforeEach(() => {
// 09:00 Berlin in June = 07:00Z.
const NINE_LOCAL = new Date("2026-06-15T07:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
describe("evaluateMeasurementReminderDue — window boundary", () => {
const reminder = { enabled: true, notifyHour: 9, nextDueAt: new Date(0) };
@@ -216,6 +218,16 @@ function makePrisma(opts: {
return { prisma, updates };
}
/** One tick against the stubbed Prisma surface: the one cast in this file's
* newer cases, rather than one per call. */
function tick(
prisma: ReturnType<typeof makePrisma>["prisma"],
now: Date,
options: Parameters<typeof runMeasurementReminderTick>[2] = {},
) {
return runMeasurementReminderTick(prisma as never, now, options);
}
function reminder(overrides: Partial<FakeReminder>): FakeReminder {
return {
id: "r1",
@@ -312,7 +324,7 @@ describe("runMeasurementReminderTick", () => {
it("dispatches a due reminder and advances nextDueAt past now (ledger-free dedup)", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({})],
reminders: [reminder({ intervalDays: null, rrule: "FREQ=WEEKLY" })],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
@@ -662,9 +674,15 @@ describe("runMeasurementReminderTick", () => {
expect("nextDueAt" in updates[0].data).toBe(false);
});
it("still rolls a weekly measurement reminder on after its reminder", async () => {
it("keeps a Coach-suggested daily measurement reminder due after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({ origin: "COACH" })],
reminders: [
reminder({
origin: "COACH",
measurementType: "WEIGHT",
intervalDays: 1,
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
@@ -672,9 +690,7 @@ describe("runMeasurementReminderTick", () => {
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates).toHaveLength(1);
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL);
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("still closes an appointment reminder after its one nudge", async () => {
@@ -728,11 +744,9 @@ describe("runMeasurementReminderTick", () => {
const sixDays = makePrisma({ reminders: [open], labMatch: null });
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(
sixDays.prisma as never,
new Date("2026-06-14T07:00:00Z"),
{ dispatch: heldDispatch },
);
await tick(sixDays.prisma, new Date("2026-06-14T07:00:00Z"), {
dispatch: heldDispatch,
});
expect(heldDispatch).not.toHaveBeenCalled();
// Seven calendar days on, at the same notify hour — a few seconds EARLIER
@@ -740,11 +754,7 @@ describe("runMeasurementReminderTick", () => {
// as "under a week".
const sevenDays = makePrisma({ reminders: [open], labMatch: null });
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
sevenDays.prisma as never,
NINE_LOCAL,
{ dispatch },
);
const summary = await tick(sevenDays.prisma, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(summary.dispatched).toBe(1);
expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
@@ -783,7 +793,7 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("rolls a weekly check-up on, since its next slot is the next nudge", async () => {
it("keeps a weekly rolling check-up due: its next slot needs it done first", async () => {
const { prisma, updates } = makePrisma({
reminders: [checkup({ rrule: null, intervalDays: 7 })],
labMatch: null,
@@ -792,8 +802,7 @@ describe("runMeasurementReminderTick", () => {
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("keeps a fortnightly check-up due", async () => {
@@ -952,3 +961,160 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
});
});
// ── A rolling reminder stays due until it is measured ────────────────────
//
// The reported shape: blood pressure every two days at 18:00, first due a
// month back, never measured since. The old tick rolled it two days on the
// moment its reminder was sent, so the card read "in 2 days" every evening
// and never "today" or "overdue".
describe("runMeasurementReminderTick — a missed rolling reminder", () => {
// 18:00 in Berlin in November (UTC+1).
const SIX_PM = new Date("2026-11-08T17:00:00.000Z");
const twoDay = (overrides: Partial<FakeReminder> = {}) =>
reminder({
measurementType: "BLOOD_PRESSURE_SYS",
intervalDays: 2,
notifyHour: 18,
// First due 4 October, local midnight; never satisfied since.
anchorDate: new Date("2026-10-03T22:00:00.000Z"),
nextDueAt: new Date("2026-10-04T16:00:00.000Z"),
createdAt: new Date("2026-11-08T09:00:00.000Z"),
...overrides,
});
it("keeps a two-day reminder due after its reminder instead of rolling it on", async () => {
const { prisma, updates } = makePrisma({
reminders: [twoDay()],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await tick(prisma, SIX_PM, {
dispatch,
});
expect(summary.dispatched).toBe(1);
expect(updates).toHaveLength(1);
expect(updates[0].data).toEqual({ lastNotifiedAt: SIX_PM });
});
it("keeps a daily and a weekly rolling reminder due the same way", async () => {
for (const intervalDays of [1, 7]) {
const { prisma, updates } = makePrisma({
reminders: [twoDay({ intervalDays })],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await tick(prisma, SIX_PM, { dispatch });
expect(updates[0].data, `every ${intervalDays} days`).toEqual({
lastNotifiedAt: SIX_PM,
});
}
});
it("repeats an open two-day reminder every second day, not every seventh", async () => {
const open = twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) });
const nextDay = makePrisma({ reminders: [open], measurementMatch: null });
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
const held = await tick(
nextDay.prisma,
new Date(SIX_PM.getTime() + 1 * DAY),
{ dispatch: heldDispatch },
);
expect(held.skippedRepeatHeld).toBe(1);
expect(heldDispatch).not.toHaveBeenCalled();
const twoDaysOn = makePrisma({ reminders: [open], measurementMatch: null });
const dispatch = vi.fn<DispatchFn>(async () => OK);
const at = new Date(SIX_PM.getTime() + 2 * DAY);
const sent = await tick(twoDaysOn.prisma, at, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
expect(sent.dispatched).toBe(1);
// Still open: the due date stays where it was.
expect(twoDaysOn.updates[0].data).toEqual({ lastNotifiedAt: at });
});
it("repeats an open daily reminder the next day", async () => {
const { prisma } = makePrisma({
reminders: [
twoDay({
intervalDays: 1,
lastNotifiedAt: new Date(SIX_PM.getTime() - 1 * DAY + 4_000),
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await tick(prisma, SIX_PM, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("still holds a long rolling cycle's repeat to a week", async () => {
const { prisma } = makePrisma({
reminders: [
twoDay({
intervalDays: 30,
lastNotifiedAt: new Date(SIX_PM.getTime() - 6 * DAY + 4_000),
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await tick(prisma, SIX_PM, {
dispatch,
});
expect(summary.skippedRepeatHeld).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
});
it("moves on from the reading that satisfies it, two days from the reading", async () => {
const measuredAt = new Date("2026-11-09T07:30:00.000Z");
const { prisma, updates } = makePrisma({
reminders: [
twoDay({ lastNotifiedAt: new Date(SIX_PM.getTime() + 4_000) }),
],
measurementMatch: { measuredAt },
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await tick(prisma, new Date(SIX_PM.getTime() + 1 * DAY), {
dispatch,
});
expect(summary.autoResolved).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
// 11 November, 18:00 in Berlin.
expect(updates[0].data.nextDueAt).toEqual(
new Date("2026-11-11T17:00:00.000Z"),
);
});
it("still rolls a weekly calendar rule on after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [
twoDay({
intervalDays: null,
rrule: "FREQ=WEEKLY",
nextDueAt: SIX_PM,
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await tick(prisma, SIX_PM, { dispatch });
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(SIX_PM.getTime());
expect(updates[0].data.lastNotifiedAt).toEqual(SIX_PM);
});
});
+49 -29
View File
@@ -9,19 +9,20 @@
*
* Differences from the mood reminder:
*
* 1. What a delivered reminder does to the row depends on its cycle.
* A reminder whose next slot is more than a week away (a fortnightly
* questionnaire, a yearly check-up) or that has no next slot at all (a
* one-shot) stays due until it is satisfied, skipped or snoozed, so the
* start page and the check-up card keep showing it as due and then
* overdue; rolling it on silently filed an open task under a later
* slot. While it stays open it is reminded again at most once a week,
* gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on
* a weekly or shorter cycle rolls on to its next slot, which is the
* next nudge anyway, and an appointment (`origin: ENCOUNTER`) is
* one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim
* in `notification_events` stays the guard against a second send on
* the same local day.
* 1. What a delivered reminder does to the row depends on its cadence.
* A rolling reminder ("every N days", anchored on its last reading),
* a calendar rule whose next slot is more than a week away (a yearly
* check-up), and a reminder with no next slot at all (a one-shot) stay
* due until they are satisfied, skipped or snoozed, so the start page
* and the check-up card keep showing them as due and then overdue;
* rolling them on silently filed an open task under a later slot. While
* one stays open it is reminded again every N days for a rolling
* interval, at most once a week, gated on `lastNotifiedAt` (see
* `isCheckupRepeatHeld`). A calendar rule that recurs within the week
* rolls on to its next slot, which is the next nudge anyway, and an
* appointment (`origin: ENCOUNTER`) is one-shot and closes (see
* `holdsOpenAfterReminder`). The per-day claim in `notification_events`
* stays the guard against a second send on the same local day.
* 2. Auto-resolve from an incoming measurement. Before deciding to
* fire, the runner checks whether a matching reading of the
* reminder's `measurementType` has landed since the last satisfy
@@ -105,31 +106,49 @@ function moduleForMeasurementType(
*/
const DUE_QUERY_SLACK_MS = 15 * 60_000;
/** Calendar days an open reminder waits before it is sent again. */
/** Calendar days an open reminder waits at most before it is sent again. */
export const CHECKUP_REPEAT_DAYS = 7;
/**
* Calendar days an open reminder waits before it is sent again: its own
* interval for a rolling reminder, capped at {@link CHECKUP_REPEAT_DAYS}, so
* a missed every-two-days reading is asked for again every second day rather
* than once a week; a week for everything else that stays open.
*/
function checkupRepeatDays(reminder: { intervalDays?: number | null }): number {
const interval = reminder.intervalDays ?? null;
if (interval === null) return CHECKUP_REPEAT_DAYS;
return Math.max(1, Math.min(interval, CHECKUP_REPEAT_DAYS));
}
/**
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
*
* Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at
* or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar
* or after `nextDueAt`) and fewer than {@link checkupRepeatDays} calendar
* days have passed in the person's timezone. Calendar days rather than a
* 7 × 24 h gap, because the tick fires within the notify hour and the next
* week's tick can land a few seconds earlier on the clock than the first
* send did.
* N × 24 h gap, because the tick fires within the notify hour and the next
* tick can land a few seconds earlier on the clock than the first send did.
*
* A slot that moved after the last nudge — a snooze to a named day, a
* Telegram "later", a skip, a satisfy — is a new slot and is never held.
*/
export function isCheckupRepeatHeld(
reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null },
reminder: {
nextDueAt: Date | null;
lastNotifiedAt?: Date | null;
intervalDays?: number | null;
},
timezone: string,
now: Date,
): boolean {
const { nextDueAt, lastNotifiedAt } = reminder;
if (!nextDueAt || !lastNotifiedAt) return false;
if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false;
return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS;
return (
calendarDaysUntil(now, lastNotifiedAt, timezone) <
checkupRepeatDays(reminder)
);
}
export interface MeasurementReminderSummary {
@@ -341,9 +360,9 @@ export async function runMeasurementReminderTick(
// there is nothing it could do this tick, so skip it before the
// auto-resolve read: the eventful `reminder-satisfy` worker resolves it
// the moment a reading or lab result lands, and the safety-net poll
// below still runs for it once a day, in the notify hour. The weekly
// below still runs for it once a day, in the notify hour. The repeat
// hold stays after that poll on purpose, so the safety net is daily
// rather than weekly.
// rather than once per repeat.
if (
reminder.lastNotifiedAt != null &&
reminder.nextDueAt !== null &&
@@ -405,8 +424,8 @@ export async function runMeasurementReminderTick(
}
// v1.39.2 — an open check-up stays due after its nudge, so without
// this it would qualify again at every notify hour. Once a week is the
// repeat; the per-day claim below is not enough on its own.
// this it would qualify again at every notify hour. Its interval, at
// most a week, is the repeat; the per-day claim is not enough alone.
if (
reminder.origin !== "ENCOUNTER" &&
isCheckupRepeatHeld(reminder, timezone, now)
@@ -529,9 +548,9 @@ export async function runMeasurementReminderTick(
continue;
}
// Dispatch succeeded. Record the delivery; a check-up keeps its due
// date (it is still open, and `lastNotifiedAt` holds the repeat to a
// week), everything else rolls on past this slot.
// Dispatch succeeded. Record the delivery; a reminder that stays due
// keeps its due date (it is still open, and `lastNotifiedAt` holds the
// repeat), a short calendar rule rolls on past this slot.
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: holdsOpenAfterReminder(reminder, timezone, now)
@@ -703,8 +722,9 @@ function nextSlotAfterReminder(
// as-is is correct. For a ROLLING reminder the engine anchors the
// first-due slot AT `anchorDate ?? createdAt` when never satisfied, which
// stays ≤ now and would re-fire every tick — so re-anchor the rolling
// cadence on `now` (a fire is the rhythm restarting from this dispatch) to
// roll it forward by exactly one interval.
// cadence on `now` to roll it forward by exactly one interval. A delivered
// rolling reminder never comes here (it stays due); the rolling arm serves
// the disabled-module skip, where nothing is left to deliver.
const rolling = reminder.intervalDays !== null;
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
@@ -6,6 +6,7 @@ import type { ReferenceRangeStatus } from "@/lib/labs/reference-range";
import { ALIAS_PANEL, BIOMARKER_PANELS } from "../biomarker-catalog";
import {
applyCategoryOrder,
categoriseOverview,
customCategoryLabel,
customPanelSuggestions,
@@ -222,3 +223,110 @@ describe("categoriseOverview", () => {
).toBe(true);
});
});
describe("applyCategoryOrder", () => {
const cats = (...keys: string[]) => keys.map((key) => ({ key }));
const keysOf = (list: { key: string }[]) => list.map((c) => c.key);
it("keeps the default order without a saved order", () => {
expect(
keysOf(applyCategoryOrder(cats("lipids", "thyroid", "other"), [])),
).toEqual(["lipids", "thyroid", "other"]);
});
it("puts the listed categories first, in the listed order", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "thyroid", "liver", "other"), [
"liver",
"lipids",
"thyroid",
"other",
]),
),
).toEqual(["liver", "lipids", "thyroid", "other"]);
});
it("ignores keys the data does not have", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "thyroid"), [
"custom:gone",
"thyroid",
"iron",
"lipids",
]),
),
).toEqual(["thyroid", "lipids"]);
});
it("adds a category the order does not name after the listed ones, in the default order", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "glucose", "thyroid", "liver"), [
"thyroid",
"lipids",
]),
),
).toEqual(["thyroid", "lipids", "glucose", "liver"]);
});
it("keeps other last when the person left it at the end", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "custom:abc", "other"), [
"lipids",
"other",
]),
),
).toEqual(["lipids", "iron", "custom:abc", "other"]);
});
it("keeps other where the person moved it", () => {
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "thyroid", "other"), [
"other",
"lipids",
"thyroid",
]),
),
).toEqual(["other", "lipids", "thyroid", "iron"]);
});
it("judges other's place among the present categories only", () => {
// "thyroid" is gone from the data, so "other" ends the listed block.
expect(
keysOf(
applyCategoryOrder(cats("lipids", "iron", "other"), [
"lipids",
"other",
"thyroid",
]),
),
).toEqual(["lipids", "iron", "other"]);
});
});
describe("categoriseOverview with a saved category order", () => {
it("answers the categories in the person's order", () => {
const { categories, grouped } = categoriseOverview(
[
marker("bm:1", "Mystery", null),
marker("bm:2", "TSH", null),
marker("bm:3", "LDL", null),
marker("bm:4", "Cortisol", "Hormones"),
],
"en",
["thyroid", "other", "lipids"],
);
expect(grouped).toBe(true);
// Hormones is not in the order: it follows the listed categories.
expect(categories.map((c) => c.label)).toEqual([
"Thyroid",
"Other",
"Lipids",
"Hormones",
]);
});
});
+21 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { normaliseLabUnit, sameLabUnit } from "../unit-normalise";
import {
normaliseLabUnit,
sameLabUnit,
withoutLabUnitAnnotation,
} from "../unit-normalise";
describe("sameLabUnit — spellings of one unit", () => {
it.each([
@@ -88,3 +92,19 @@ describe("normaliseLabUnit", () => {
expect(normaliseLabUnit("MG/DL")).toBe("mg/dL");
});
});
describe("a calculated-value note in the unit", () => {
it("is not part of the unit", () => {
expect(sameLabUnit("g/dL (calc)", "g/dL")).toBe(true);
expect(sameLabUnit("mmol/l (berechnet)", "mmol/L")).toBe(true);
expect(sameLabUnit("mg/dL (Calc.)", "MG/DL")).toBe(true);
expect(withoutLabUnitAnnotation("(calc)")).toBe("");
expect(withoutLabUnitAnnotation(" g/dL (calculated) ")).toBe("g/dL");
});
it("keeps any other bracket, so it still surfaces as a mismatch", () => {
expect(sameLabUnit("mg/dL (fasting)", "mg/dL")).toBe(false);
expect(withoutLabUnitAnnotation("U/L (37 °C)")).toBe("U/L (37 °C)");
expect(withoutLabUnitAnnotation("(calc) g/dL")).toBe("(calc) g/dL");
});
});
+36 -3
View File
@@ -191,14 +191,47 @@ export interface CategorisableMarker {
}
/**
* Every category of an overview, in display order (catalog panels in catalog
* order, then the person's own by label, then "other"), and whether the list
* The person's category order (`categoryOrder` in the labs list layout)
* over categories already in the default order.
*
* Listed keys come first, in the listed order; a key the data does not have
* is skipped. A category the list does not name (a panel added since the
* person arranged the list) follows the listed ones in the default order.
* "other" is the one exception: left at the end of the listed categories it
* stays last, after any category added later, because a catch-all belongs
* at the bottom unless the person moved it up. An empty order is the
* default order.
*/
export function applyCategoryOrder<T extends { key: string }>(
categories: readonly T[],
order: readonly string[],
): T[] {
if (order.length === 0) return [...categories];
const present = new Set(categories.map((category) => category.key));
const listed = order.filter((key) => present.has(key));
const rank = new Map(listed.map((key, index) => [key, index]));
const ordered = categories
.filter((category) => rank.has(category.key))
.sort((a, b) => rank.get(a.key)! - rank.get(b.key)!);
const rest = categories.filter((category) => !rank.has(category.key));
const otherLast = ordered.at(-1)?.key === OTHER_CATEGORY;
if (!otherLast || rest.length === 0) return [...ordered, ...rest];
return [...ordered.slice(0, -1), ...rest, ordered.at(-1)!];
}
/**
* Every category of an overview, in display order, and whether the list
* groups at all: it does when there are two categories or more and at least
* one is not "other". One category, or only "other", stays a flat list.
*
* The default order is catalog panels in catalog order (`BIOMARKER_PANELS`),
* then the person's own by label, then "other"; `categoryOrder`, the
* person's own arrangement, is applied over it (`applyCategoryOrder`).
*/
export function categoriseOverview(
markers: readonly CategorisableMarker[],
locale: Locale,
categoryOrder: readonly string[] = [],
): { categories: LabOverviewCategory[]; grouped: boolean } {
const { t } = getServerTranslator(locale);
const buckets = new Map<
@@ -264,5 +297,5 @@ export function categoriseOverview(
const grouped =
categories.length >= 2 &&
categories.some((category) => category.key !== OTHER_CATEGORY);
return { categories, grouped };
return { categories: applyCategoryOrder(categories, categoryOrder), grouped };
}
+23 -1
View File
@@ -21,9 +21,31 @@
* does not recognise is compared exactly, so an unfamiliar spelling surfaces as
* a mismatch rather than being guessed at.
*
* A lab's note that a value was calculated rather than measured (`g/dL
* (calc)`, `(berechnet)`) is not part of the unit and is dropped first; see
* `withoutLabUnitAnnotation`.
*
* Pure and dependency-free.
*/
/**
* A trailing note in brackets that says the lab calculated the value. Some
* reports print it in the unit column, and a read that copies the column
* verbatim then minted a marker in "g/dL (calc)", which refused the next
* report's plain "g/dL". Only these words are dropped, so a bracket that
* carries meaning stays and still surfaces as a mismatch.
*/
const CALCULATED_NOTE =
/\s*\((?:calc\.?|calculated|computed|berechnet|ber\.?)\)\s*$/i;
/**
* The unit without a trailing calculated-value note, trimmed: `g/dL (calc)`
* reads `g/dL`, and a unit that was only the note reads `""`.
*/
export function withoutLabUnitAnnotation(raw: string): string {
return raw.replace(CALCULATED_NOTE, "").trim();
}
/** Units that may be recognised when printed entirely in capitals. */
const SHOUTED_UNITS: ReadonlyMap<string, string> = new Map(
[
@@ -79,7 +101,7 @@ function foldLitre(text: string): string {
* means the same thing, and the (trimmed) string itself otherwise.
*/
export function normaliseLabUnit(raw: string): string {
const spaced = foldSpacing(raw);
const spaced = foldSpacing(withoutLabUnitAnnotation(raw));
const hasLowercase = /[a-zµμ]/.test(spaced);
const hasUppercase = /[A-Z]/.test(spaced);
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
import {
HOLD_OPEN_MIN_CYCLE_DAYS,
hasLongCycle,
holdsOpenAfterReminder,
} from "../holds-open";
@@ -35,13 +36,17 @@ describe("holdsOpenAfterReminder", () => {
expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
});
it("lets a weekly or shorter interval roll on", () => {
expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe(
false,
);
expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe(
false,
);
// A rolling interval is anchored on its last satisfaction, so its next
// slot only exists once it is measured: rolling it on after its reminder
// re-anchored it on the send instead, and a missed every-two-days blood
// pressure reading read "in 2 days" each evening and never "overdue".
it("holds every rolling interval open, however short", () => {
for (const intervalDays of [1, 2, 3, 7]) {
expect(
holdsOpenAfterReminder(r({ intervalDays }), TZ, SLOT),
`every ${intervalDays} days`,
).toBe(true);
}
});
it("reads a rule by the gap to its next occurrence", () => {
@@ -109,3 +114,32 @@ describe("holdsOpenAfterReminder", () => {
).toBe(false);
});
});
// The digest's narrower question: whether a reminder is infrequent enough to
// sit on the start page for its whole due day. A daily weigh-in at 20:00 is
// not, even though it now stays due once its reminder is sent.
describe("hasLongCycle", () => {
it("reads a rolling interval by its length", () => {
expect(hasLongCycle(r({ intervalDays: 1 }), TZ, SLOT)).toBe(false);
expect(hasLongCycle(r({ intervalDays: 7 }), TZ, SLOT)).toBe(false);
expect(hasLongCycle(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
});
it("agrees with holdsOpenAfterReminder on every rule and on no cadence", () => {
for (const rrule of [
"FREQ=YEARLY;INTERVAL=1",
"FREQ=WEEKLY",
"FREQ=DAILY;BYHOUR=9,19",
"FREQ=DAILY;UNTIL=20260926T235959Z",
null,
]) {
expect(hasLongCycle(r({ rrule }), TZ, SLOT), String(rrule)).toBe(
holdsOpenAfterReminder(r({ rrule }), TZ, SLOT),
);
}
});
it("never counts an appointment", () => {
expect(hasLongCycle(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(false);
});
});
@@ -8,8 +8,10 @@ import { describe, it, expect } from "vitest";
import {
byHourTimesOfDay,
computeReminderNextDueAt,
parseReminderAnchor,
type ReminderScheduleInput,
} from "../scheduling";
import { relativeDueKey } from "../due-day";
const TZ = "Europe/Berlin";
@@ -52,6 +54,26 @@ describe("computeReminderNextDueAt", () => {
expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z");
});
// The create and edit routes compute `nextDueAt` with exactly this call.
// A first due date weeks back, never measured since, stays on that day, so
// the card reads it as overdue: it is not walked forward past now.
it("rolling, never satisfied, first due weeks back: stays on the anchor day", () => {
const now = new Date("2026-11-08T19:40:00Z"); // 20:40 in Berlin
const reminder = base({
intervalDays: 2,
notifyHour: 18,
anchorDate: parseReminderAnchor("2026-10-04", TZ),
createdAt: now,
});
const next = computeReminderNextDueAt(reminder, TZ, now);
// 4 October, 18:00 in Berlin (UTC+2).
expect(next).toEqual(new Date("2026-10-04T16:00:00Z"));
expect(relativeDueKey(next!.toISOString(), now.getTime(), TZ)).toEqual({
key: "measurementReminders.overdueByDays",
days: 35,
});
});
it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => {
const reminder = base({
intervalDays: 7,
+48 -19
View File
@@ -1,22 +1,32 @@
/**
* v1.39.2 — whether a reminder stays due after it is sent.
*
* Decided by the cycle length, not by what the reminder asks for. A reminder
* whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the
* current one stays due until it is satisfied (a matching reading or lab
* result, or "done"), skipped or snoozed: rolling it on would file an open
* task under a slot weeks or a year away. That holds for free-text check-ups
* and measurement reminders alike; the report that led here was a fortnightly
* PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no
* occurrence left, has no next slot and also stays due: its last slot is the
* task. A weekly or shorter cycle rolls on, because its next slot is the next
* reminder anyway, and holding a daily course on a missed slot would stall it.
* An appointment (`ENCOUNTER`) is always one-shot.
* A reminder that stays due is not moved by its reminder: it keeps its due
* date until it is satisfied (a matching reading or lab result, or "done"),
* skipped or snoozed, so every surface reads it as due and then overdue.
*
* - A rolling interval (`intervalDays`) always stays due. It is anchored on
* its last satisfaction, so its next slot does not exist until it is
* satisfied; rolling it on after a reminder re-anchored it on the send,
* and a missed every-two-days blood pressure reading read "in 2 days"
* each evening and was never shown as due or overdue.
* - A calendar rule (`rrule`) stays due when its next occurrence is more
* than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days away: rolling it on would
* file an open task under a slot weeks or a year away. That holds for
* free-text check-ups and measurement reminders alike; the report that
* led here was a fortnightly PHQ-9 and GAD-7 pair. A rule that recurs
* within the week rolls on, because its next occurrence is the next
* reminder anyway and is fixed by the calendar, not by the last reading;
* holding a twice-daily Coach course on a missed slot would stall it.
* - A reminder with no cadence, or a rule with no occurrence left, has no
* next slot and stays due: its last slot is the task.
* - An appointment (`ENCOUNTER`) is always one-shot.
*
* The one definition. The reminder tick decides with it whether a delivered
* reminder rolls on; the daily digest decides with it whether a reminder due
* later today belongs on the start page already and keeps its place there;
* the edit route decides with it whether re-enabling keeps the open slot.
* reminder rolls on; the edit route decides with it whether re-enabling keeps
* the open slot. The daily digest asks the narrower question underneath it,
* {@link hasLongCycle}: whether a reminder is infrequent enough to sit on the
* start page for its whole due day.
*
* A course window (`endsOn`) is deliberately not consulted: it ends a
* Coach-suggested measurement course, which is a short-cycle rhythm and rolls
@@ -25,7 +35,7 @@
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
/** A cycle of at most this many calendar days rolls on after its reminder. */
/** A calendar rule recurring within this many calendar days rolls on after its reminder. */
export const HOLD_OPEN_MIN_CYCLE_DAYS = 7;
export interface HoldOpenInput {
@@ -41,15 +51,34 @@ export interface HoldOpenInput {
/**
* `slot` is the instant of the current slot: the send instant in the tick
* (a few seconds past the occurrence it sends), the stored `nextDueAt`
* elsewhere. For a rule the cycle is the calendar-day gap between its next
* two occurrences from `slot` on, so the answer does not depend on where
* between two occurrences `slot` sits. With one occurrence left the gap is
* measured from `slot` to it; with none left the reminder holds open.
* elsewhere.
*/
export function holdsOpenAfterReminder(
reminder: HoldOpenInput,
timezone: string,
slot: Date,
): boolean {
if (reminder.origin === "ENCOUNTER") return false;
if (reminder.intervalDays !== null) return true;
return hasLongCycle(reminder, timezone, slot);
}
/**
* Whether the reminder's cycle is longer than {@link HOLD_OPEN_MIN_CYCLE_DAYS}
* days, or it has no next slot at all. The daily digest puts such a reminder
* on the start page from the morning of its due day and keeps it there under
* the rail cap; a short rhythm (a daily weigh-in at 20:00) waits until its
* time has come.
*
* For a rule the cycle is the calendar-day gap between its next two
* occurrences from `slot` on, so the answer does not depend on where between
* two occurrences `slot` sits. With one occurrence left the gap is measured
* from `slot` to it; with none left the cycle counts as long.
*/
export function hasLongCycle(
reminder: HoldOpenInput,
timezone: string,
slot: Date,
): boolean {
if (reminder.origin === "ENCOUNTER") return false;
if (reminder.intervalDays !== null) {
@@ -105,6 +105,9 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// derivation as PHQ-9 / GAD-7. No HK mapping by design.
"WHO5_SCORE",
"SCI_SCORE",
// Blood ketones (#1367): HealthKit has no beta-hydroxybutyrate quantity
// type, so a ketone reading is typed by hand. No HK mapping by design.
"BLOOD_KETONES",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import { MGDL_PER_MMOL } from "@/lib/glucose";
import {
GKI_PAIRING_WINDOW_MINUTES,
glucoseKetoneIndex,
pairGlucoseKetoneIndex,
summariseGlucoseKetoneIndex,
type GkiReading,
} from "@/lib/measurements/glucose-ketone-index";
const T0 = Date.parse("2026-11-01T08:00:00.000Z");
const MIN = 60_000;
function reading(
id: string,
offsetMin: number,
value: number,
source = "MANUAL",
): GkiReading {
return { id, measuredAt: new Date(T0 + offsetMin * MIN), value, source };
}
describe("glucoseKetoneIndex", () => {
it("divides glucose in mmol/L by ketones, converting with the one factor", () => {
// 90 mg/dL = 4.995 mmol/L; over 1.5 mmol/L ketones.
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(90 / MGDL_PER_MMOL / 1.5);
expect(glucoseKetoneIndex(90, 1.5)).toBeCloseTo(3.33, 2);
});
it("has no index without ketones or with a value that is not positive", () => {
expect(glucoseKetoneIndex(90, 0)).toBeNull();
expect(glucoseKetoneIndex(90, -1)).toBeNull();
expect(glucoseKetoneIndex(0, 1)).toBeNull();
expect(glucoseKetoneIndex(Number.NaN, 1)).toBeNull();
expect(glucoseKetoneIndex(90, Number.POSITIVE_INFINITY)).toBeNull();
});
});
describe("pairGlucoseKetoneIndex", () => {
it("pairs a fingerstick written with one timestamp", () => {
const [point, ...rest] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1.2)],
[reading("g1", 0, 81)],
);
expect(rest).toEqual([]);
expect(point).toEqual({
ketoneId: "k1",
glucoseId: "g1",
at: new Date(T0).toISOString(),
glucoseAt: new Date(T0).toISOString(),
// 81 / 18.0182 = 4.4954 mmol/L; / 1.2 = 3.746 -> 3.7
value: 3.7,
ketonesMmol: 1.2,
glucoseMmol: 4.5,
});
});
it("prefers the same instant over a nearer-looking neighbour", () => {
const [point] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-early", -1, 200), reading("g-same", 0, 90)],
);
expect(point?.glucoseId).toBe("g-same");
});
it("takes the nearest glucose inside the window, before or after", () => {
const [before] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-far", -14, 100), reading("g-near", -3, 90)],
);
expect(before?.glucoseId).toBe("g-near");
const [after] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-after", 4, 90), reading("g-before", -9, 100)],
);
expect(after?.glucoseId).toBe("g-after");
});
it("pairs at the window's edge and never beyond it", () => {
const edge = GKI_PAIRING_WINDOW_MINUTES;
expect(
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", edge, 90)]),
).toHaveLength(1);
expect(
pairGlucoseKetoneIndex([reading("k1", 0, 1)], [reading("g1", -edge, 90)]),
).toHaveLength(1);
expect(
pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g1", edge + 0.5, 90), reading("g2", -(edge + 1), 90)],
),
).toEqual([]);
});
it("leaves out a ketone reading of zero or without a partner", () => {
expect(
pairGlucoseKetoneIndex(
[reading("k-zero", 0, 0), reading("k-alone", 120, 1.4)],
[reading("g1", 0, 90)],
),
).toEqual([]);
});
it("breaks a tie by source, then by the earlier reading", () => {
const [bySource] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1, "MANUAL")],
[reading("g-cgm", 0, 140, "APPLE_HEALTH"), reading("g-own", 0, 90)],
);
expect(bySource?.glucoseId).toBe("g-own");
const [byTime] = pairGlucoseKetoneIndex(
[reading("k1", 0, 1)],
[reading("g-later", 5, 100), reading("g-earlier", -5, 90)],
);
expect(byTime?.glucoseId).toBe("g-earlier");
});
it("gives the same pair for a full tie whatever order the rows came in", () => {
const a = reading("g-a", 0, 90, "APPLE_HEALTH");
const b = reading("g-b", 0, 110, "NIGHTSCOUT");
for (const glucose of [
[a, b],
[b, a],
]) {
const [point] = pairGlucoseKetoneIndex([reading("k1", 0, 1)], glucose);
expect(point?.glucoseId).toBe("g-a");
}
});
it("answers in ketone order whatever order the readings came in", () => {
const points = pairGlucoseKetoneIndex(
[reading("k2", 60, 2), reading("k1", 0, 1)],
[reading("g2", 61, 90), reading("g1", 1, 90)],
);
expect(points.map((p) => [p.ketoneId, p.glucoseId])).toEqual([
["k1", "g1"],
["k2", "g2"],
]);
});
it("lets one glucose reading serve two ketone readings and names it", () => {
const points = pairGlucoseKetoneIndex(
[reading("k1", 0, 1), reading("k2", 6, 2)],
[reading("g1", 3, 90)],
);
expect(points.map((p) => p.glucoseId)).toEqual(["g1", "g1"]);
expect(points[1]!.value).toBeLessThan(points[0]!.value);
});
});
describe("summariseGlucoseKetoneIndex", () => {
it("summarises the indices and is null for none", () => {
expect(summariseGlucoseKetoneIndex([])).toBeNull();
const points = pairGlucoseKetoneIndex(
[reading("k1", 0, 1), reading("k2", 60, 2), reading("k3", 120, 4)],
[reading("g1", 0, 90), reading("g2", 60, 90), reading("g3", 120, 90)],
);
// 90 mg/dL = 4.995 mmol/L: indices 5.0, 2.5, 1.2.
expect(points.map((p) => p.value)).toEqual([5, 2.5, 1.2]);
expect(summariseGlucoseKetoneIndex(points)).toEqual({
count: 3,
mean: 2.9,
min: 1.2,
max: 5,
latest: 1.2,
});
});
});
+1
View File
@@ -128,6 +128,7 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// ── Metabolic ──
["BLOOD_GLUCOSE", "metabolic"],
["BLOOD_KETONES", "metabolic"],
["SKIN_TEMPERATURE", "metabolic"],
// v1.10.0 — wrist temperature is an overnight skin-side reading;
// it sits with skin temperature in the metabolic cluster.
@@ -0,0 +1,193 @@
/**
* The glucose-ketone index (GKI), derived from a ketone reading and the
* glucose reading taken with it (#1367).
*
* GKI = glucose (mmol/L) / beta-hydroxybutyrate (mmol/L)
*
* Glucose is stored in mg/dL and converted with {@link MGDL_PER_MMOL}, the one
* factor the tree uses for that conversion, so the glucose a GKI is built from
* is the same number every glucose surface shows. The index is computed when
* it is read and never stored: a stored ratio drifts the moment either reading
* it came from is edited or deleted, which is the failure a hand-typed GKI has.
*
* Pairing, per ketone reading:
*
* 1. A glucose reading at the same instant wins. A fingerstick entry writes
* both readings with one timestamp, so this is the usual case.
* 2. Otherwise the nearest glucose reading no more than
* {@link GKI_PAIRING_WINDOW_MINUTES} minutes before or after it.
* 3. Nothing further away. A ketone reading with no glucose inside the
* window, or with a value of zero, has no index.
*
* Ties at the same distance go to the reading from the same source as the
* ketone reading (a manual fingerstick pairs with the manual glucose rather
* than with a sensor reading at the same minute), then to the earlier one. One
* glucose reading may serve two ketone readings taken close together; each
* index names the glucose reading it was built from.
*
* The figure is descriptive. Nothing here grades it: what a GKI should be is a
* matter for the plan a clinician sets.
*/
import { MGDL_PER_MMOL } from "@/lib/glucose";
/**
* The widest gap between a ketone and a glucose reading that still pairs.
*
* Fifteen minutes covers two strips taken one after the other, a meter that
* reads ketones from a second drop, a reading typed in a few minutes late, and
* a sensor that samples every five minutes, while staying short enough that
* glucose has not moved on: after a meal it can rise by a few mg/dL a minute,
* so an hour-old reading describes a different moment and the ratio built from
* it would describe neither.
*/
export const GKI_PAIRING_WINDOW_MINUTES = 15;
/**
* The unit both components of the index are in. Fixed, not the reader's
* glucose unit: the index is defined on mmol/L, and a ratio taken over mg/dL
* would be eighteen times larger and mean something else.
*/
export const GKI_COMPONENT_UNIT = "mmol/L";
export const GKI_PAIRING_WINDOW_MS = GKI_PAIRING_WINDOW_MINUTES * 60_000;
/** One stored reading, as the pairing needs it. */
export interface GkiReading {
id: string;
measuredAt: Date;
/** Ketones in mmol/L; glucose in canonical mg/dL. */
value: number;
source: string;
}
/** One derived index, with the two readings it was built from. */
export interface GlucoseKetoneIndexPoint {
/** The ketone reading's id. The index belongs to the ketone reading. */
ketoneId: string;
/** The glucose reading it was paired with. */
glucoseId: string;
/** The ketone reading's instant, ISO 8601. */
at: string;
/** The glucose reading's instant, ISO 8601. */
glucoseAt: string;
/** The index, to one decimal. */
value: number;
/** Beta-hydroxybutyrate as recorded, mmol/L. */
ketonesMmol: number;
/** The paired glucose in mmol/L, to one decimal. */
glucoseMmol: number;
}
/**
* The index of one glucose (mg/dL) and one ketone (mmol/L) value, unrounded,
* or null when it is undefined: no ketones, or a value that is not a positive
* finite number on either side.
*/
export function glucoseKetoneIndex(
glucoseMgdl: number,
ketonesMmol: number,
): number | null {
if (!Number.isFinite(glucoseMgdl) || !Number.isFinite(ketonesMmol)) {
return null;
}
if (ketonesMmol <= 0 || glucoseMgdl <= 0) return null;
return glucoseMgdl / MGDL_PER_MMOL / ketonesMmol;
}
function oneDecimal(value: number): number {
return Math.round(value * 10) / 10;
}
/**
* Pair every ketone reading with its glucose reading and compute the index.
* Pure. Both lists may come in any order; the answer is in ketone-reading
* order (oldest first). Ketone readings without a partner are left out.
*/
export function pairGlucoseKetoneIndex(
ketones: readonly GkiReading[],
glucose: readonly GkiReading[],
): GlucoseKetoneIndexPoint[] {
// The id breaks the last tie, so two glucose readings alike in time and
// source always give the same pair, whatever order the rows came in.
const sortedGlucose = [...glucose].sort(
(a, b) =>
a.measuredAt.getTime() - b.measuredAt.getTime() ||
a.id.localeCompare(b.id),
);
const sortedKetones = [...ketones].sort(
(a, b) =>
a.measuredAt.getTime() - b.measuredAt.getTime() ||
a.id.localeCompare(b.id),
);
const out: GlucoseKetoneIndexPoint[] = [];
let lo = 0;
for (const ketone of sortedKetones) {
if (!(ketone.value > 0)) continue;
const at = ketone.measuredAt.getTime();
// Ketone readings are walked in time order, so the window's lower edge
// only ever moves forward.
while (
lo < sortedGlucose.length &&
sortedGlucose[lo]!.measuredAt.getTime() < at - GKI_PAIRING_WINDOW_MS
) {
lo++;
}
let best: GkiReading | null = null;
let bestRank: [number, number, number] | null = null;
for (let i = lo; i < sortedGlucose.length; i++) {
const candidate = sortedGlucose[i]!;
const t = candidate.measuredAt.getTime();
if (t > at + GKI_PAIRING_WINDOW_MS) break;
const rank: [number, number, number] = [
Math.abs(t - at),
candidate.source === ketone.source ? 0 : 1,
t,
];
if (
bestRank === null ||
rank[0] < bestRank[0] ||
(rank[0] === bestRank[0] &&
(rank[1] < bestRank[1] ||
(rank[1] === bestRank[1] && rank[2] < bestRank[2])))
) {
best = candidate;
bestRank = rank;
}
}
if (best === null) continue;
const index = glucoseKetoneIndex(best.value, ketone.value);
if (index === null) continue;
out.push({
ketoneId: ketone.id,
glucoseId: best.id,
at: ketone.measuredAt.toISOString(),
glucoseAt: best.measuredAt.toISOString(),
value: oneDecimal(index),
ketonesMmol: ketone.value,
glucoseMmol: oneDecimal(best.value / MGDL_PER_MMOL),
});
}
return out;
}
/** Count, mean, min, max and latest of a run of indices, or null when empty. */
export interface GlucoseKetoneIndexSummary {
count: number;
mean: number;
min: number;
max: number;
latest: number;
}
export function summariseGlucoseKetoneIndex(
points: readonly GlucoseKetoneIndexPoint[],
): GlucoseKetoneIndexSummary | null {
if (points.length === 0) return null;
const values = points.map((p) => p.value);
return {
count: values.length,
mean: oneDecimal(values.reduce((a, b) => a + b, 0) / values.length),
min: Math.min(...values),
max: Math.max(...values),
latest: values[values.length - 1]!,
};
}
@@ -0,0 +1,84 @@
/**
* Read the glucose-ketone index of a window (#1367): the ketone readings in
* it and the glucose readings within pairing reach of one, paired by
* `pairGlucoseKetoneIndex`. Every surface that shows the index (the series
* route, the day, the doctor report) reads it here, so the pairing rule has
* one implementation.
*
* Glucose and ketones carry no source ladder (neither is in `RANKED_TYPES`),
* so every live row of every source is a candidate, as on the glucose chart.
* The glucose read is bounded by the ketone readings: only rows within the
* pairing window of one are fetched, so a sensor writing every five minutes
* contributes a handful of rows per fingerstick, not the window's thousands.
*/
import { prisma } from "@/lib/db";
import {
GKI_PAIRING_WINDOW_MINUTES,
pairGlucoseKetoneIndex,
type GkiReading,
type GlucoseKetoneIndexPoint,
} from "./glucose-ketone-index";
interface Row {
id: string;
value: number;
measured_at: Date;
source: string;
}
function toReading(row: Row): GkiReading {
return {
id: row.id,
value: row.value,
measuredAt: row.measured_at,
source: row.source,
};
}
/**
* The indices of the ketone readings taken in `[from, to)`, oldest first.
* A glucose reading just outside the window still pairs with a ketone reading
* just inside it.
*/
export async function readGlucoseKetoneIndex(args: {
userId: string;
from: Date;
to: Date;
}): Promise<GlucoseKetoneIndexPoint[]> {
const { userId, from, to } = args;
const ketones = await prisma.$queryRaw<Row[]>`
SELECT k."id", k."value", k."measured_at", k."source"::text AS "source"
FROM measurements k
WHERE k."user_id" = ${userId}
AND k."type" = 'BLOOD_KETONES'::"measurement_type"
AND k."deleted_at" IS NULL
AND k."measured_at" >= ${from}
AND k."measured_at" < ${to}
AND k."value" > 0
ORDER BY k."measured_at" ASC, k."id" ASC
`;
if (ketones.length === 0) return [];
// An interval between two stored instants: no zone enters the arithmetic.
const reach = `${GKI_PAIRING_WINDOW_MINUTES} minutes`;
// Driven from the ketone side: one index range per ketone reading on
// (user_id, type, measured_at), so a person with years of CGM glucose
// reads only the glucose around their fingersticks, never the series. A
// correlated EXISTS over a range had no equality to drive it the other
// way and walked every glucose row in the window.
const at = ketones.map((k) => k.measured_at);
const glucose = await prisma.$queryRaw<Row[]>`
SELECT DISTINCT g."id", g."value", g."measured_at", g."source"
FROM unnest(${at}::timestamp(3)[]) AS k("at")
CROSS JOIN LATERAL (
SELECT m."id", m."value", m."measured_at", m."source"::text AS "source"
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type"
AND m."deleted_at" IS NULL
AND m."measured_at" BETWEEN k."at" - ${reach}::interval
AND k."at" + ${reach}::interval
) g
`;
return pairGlucoseKetoneIndex(ketones.map(toReading), glucose.map(toReading));
}
@@ -44,7 +44,9 @@ export type SeriesKind =
| "oxygenSaturation"
| "restingHeartRate"
| "heartRateVariability"
| "vo2Max";
| "vo2Max"
| "ketones"
| "glucoseKetoneIndex";
export interface SeriesReferenceLine {
value: number;
+23
View File
@@ -72,6 +72,10 @@ export const kindEnum = z.enum([
"restingHeartRate",
"heartRateVariability",
"vo2Max",
// #1367 — blood ketones, and the glucose-ketone index derived from them and
// the glucose reading taken with each (`glucose-ketone-index.ts`).
"ketones",
"glucoseKetoneIndex",
]);
export type SeriesKind = z.infer<typeof kindEnum>;
@@ -90,6 +94,11 @@ export const KIND_TO_TYPE: Record<SeriesKind, MeasurementType> = {
restingHeartRate: "RESTING_HEART_RATE",
heartRateVariability: "HEART_RATE_VARIABILITY",
vo2Max: "VO2_MAX",
ketones: "BLOOD_KETONES",
// The index has no stored type. It belongs to its ketone reading, so the
// change direction and day values read as the ketone series' would; neither
// has a band, so nothing is judged.
glucoseKetoneIndex: "BLOOD_KETONES",
};
/**
@@ -119,6 +128,9 @@ export const SERIES_UNIT: Record<SeriesKind, string> = {
restingHeartRate: "bpm",
heartRateVariability: "ms",
vo2Max: "mL/(kg·min)",
ketones: "mmol/L",
// A ratio of two concentrations in the same unit: dimensionless.
glucoseKetoneIndex: "ratio",
};
export interface SeriesPoint {
@@ -142,6 +154,17 @@ export interface SeriesPoint {
*/
valueMin?: number | null;
valueMax?: number | null;
/**
* #1367 — the two readings a `kind=glucoseKetoneIndex` point was built
* from. Absent for every other kind. `id` and `at` are the ketone
* reading's; this names the glucose reading paired with it.
*/
gki?: {
glucoseId: string;
glucoseAt: string;
glucoseMmol: number;
ketonesMmol: number;
};
}
export function stdDev(values: number[]): number {
@@ -0,0 +1,37 @@
/**
* `kind=glucoseKetoneIndex`: one point per ketone reading that pairs with a
* glucose reading (#1367). The index is derived at read time by the one
* pairing rule (`read-glucose-ketone-index.ts`); a client draws the value it
* is given and never recomputes it. Ketone readings are a few a day at most,
* so the window is never bucketed.
*/
import { readGlucoseKetoneIndex } from "@/lib/measurements/read-glucose-ketone-index";
import type { SeriesRead } from "./kinds";
export async function readGlucoseKetoneIndexSeries(
userId: string,
since: Date,
): Promise<SeriesRead> {
const points = await readGlucoseKetoneIndex({
userId,
from: since,
// Open-ended: a reading stamped a moment ahead of the server clock is
// still the latest one.
to: new Date(Date.now() + 86_400_000),
});
return {
points: points.map((p) => ({
id: p.ketoneId,
at: p.at,
value: p.value,
secondary: null,
gki: {
glucoseId: p.glucoseId,
glucoseAt: p.glucoseAt,
glucoseMmol: p.glucoseMmol,
ketonesMmol: p.ketonesMmol,
},
})),
};
}
@@ -28,6 +28,7 @@ import {
} from "./kinds";
import { readBloodPressureSeries } from "./read-blood-pressure";
import { readBucketedSeries, takesBucketedRead } from "./read-bucketed";
import { readGlucoseKetoneIndexSeries } from "./read-glucose-ketone-index";
import { readRawSeries } from "./read-raw";
import { readSleepSeries } from "./read-sleep";
@@ -40,6 +41,9 @@ async function readPoints(
): Promise<SeriesRead> {
if (kind === "sleep") return readSleepSeries(userId, since, days);
if (kind === "bloodPressure") return readBloodPressureSeries(userId, since);
if (kind === "glucoseKetoneIndex") {
return readGlucoseKetoneIndexSeries(userId, since);
}
if (await takesBucketedRead(userId, kind, days, since)) {
return readBucketedSeries(userId, kind, days, since);
}
+1
View File
@@ -22,6 +22,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
SLEEP_DURATION: "measurements.typeSleep",
ACTIVITY_STEPS: "measurements.typeSteps",
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
BLOOD_KETONES: "measurements.typeBloodKetones",
TOTAL_BODY_WATER: "measurements.typeTotalBodyWater",
BONE_MASS: "measurements.typeBoneMass",
OXYGEN_SATURATION: "measurements.typeOxygenSaturation",
+1
View File
@@ -21,6 +21,7 @@ const unitMap: Record<string, string> = {
SLEEP_DURATION: "minutes",
ACTIVITY_STEPS: "steps",
BLOOD_GLUCOSE: "mg/dL",
BLOOD_KETONES: "mmol/L",
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
OXYGEN_SATURATION: "%",
+6
View File
@@ -20,6 +20,12 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
SLEEP_DURATION: { min: 0, max: 1440 },
ACTIVITY_STEPS: { min: 0, max: 200000 },
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
// Beta-hydroxybutyrate, mmol/L. The blood ketone meters people type these
// off (Abbott Precision Xtra / FreeStyle Optium, Keto-Mojo, Nova Max Plus)
// measure from 0.0 or 0.1 up to 8.0 mmol/L and print "HI" above it; 10
// leaves room for a venous value read off a lab sheet. A meter reads 0.0,
// so zero is a real reading, not an empty one.
BLOOD_KETONES: { min: 0, max: 10 },
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
// Pulse oximetry (SpO2). BTS Guideline 2017 + ATS clinical practice put the
@@ -86,6 +86,21 @@ describe("crisisResourcesForLocale", () => {
}
});
it("gives a Korean reader 119 and the 109 suicide prevention line", () => {
for (const locale of ["ko", "ko-KR", "ko_KR", "kr", "en-KR"]) {
const set = crisisResourcesForLocale(locale);
expect(set.emergencyNumber, locale).toBe("119");
expect(set.resources, locale).toEqual([
{ id: "suicidePrevention109", contacts: ["109"] },
{ id: "findahelpline", contacts: ["findahelpline.com"] },
]);
}
const { t } = getServerTranslator("ko");
expect(
t("mentalHealth.crisisResource.suicidePrevention109.name"),
).toContain("109");
});
it("resolves the country from the region subtag whatever the language", () => {
const cases: Array<[string, string]> = [
["en-BE", "zelfmoordlijn1813"],
@@ -143,6 +158,7 @@ describe("crisisResourcesForLocale", () => {
"en-US",
"fr",
"nl",
"ko",
].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)),
);
for (const locale of locales) {
+25 -2
View File
@@ -178,6 +178,20 @@ const BE: CrisisResourceSet = {
],
};
/**
* South Korea (the region "kr", and Korean with no region, which is read
* almost only there). 119 is the emergency number for an ambulance. Since
* January 2024 the national suicide prevention line 109 has taken over the
* earlier counselling numbers and answers around the clock.
*/
const KR: CrisisResourceSet = {
emergencyNumber: "119",
resources: [
{ id: "suicidePrevention109", contacts: ["109"] },
{ id: "findahelpline", contacts: ["findahelpline.com"] },
],
};
/**
* Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in
* the Netherlands and in Flanders alike, and the account keeps no country to
@@ -205,6 +219,7 @@ const BY_REGION: Readonly<Record<string, CrisisResourceSet>> = {
br: BR,
ch: CH,
de: DE,
kr: KR,
nl: NL,
pt: PT,
us: US,
@@ -235,7 +250,7 @@ function regionSubtag(tag: string): string | null {
* bundle (see PT_BR_AMBIGUOUS), so it keeps the ambiguous set. The web stores
* the app locale, which is otherwise a bare "de", "nl", "en", so it lands on
* the combined sets: never assume Germany, the Netherlands or Brazil when the
* region is unknown. A bare country code ("at", "ch", "us", "br", "be")
* region is unknown. A bare country code ("at", "ch", "us", "br", "be", "kr")
* still resolves to that country.
*/
export function crisisResourcesForLocale(
@@ -248,7 +263,14 @@ export function crisisResourcesForLocale(
const byRegion = BY_REGION[region];
if (byRegion) return byRegion;
}
if (lc === "at" || lc === "ch" || lc === "us" || lc === "br" || lc === "be") {
if (
lc === "at" ||
lc === "ch" ||
lc === "us" ||
lc === "br" ||
lc === "be" ||
lc === "kr"
) {
return BY_REGION[lc];
}
const language = lc.split("-")[0];
@@ -258,5 +280,6 @@ export function crisisResourcesForLocale(
if (language === "pt") return PT_BR_AMBIGUOUS;
// The bare "nl" the web stores, and any other Dutch tag: both countries.
if (language === "nl") return NL_BE;
if (language === "ko") return KR;
return INTERNATIONAL;
}
+39 -1
View File
@@ -2,7 +2,8 @@
/**
* Per-list presentation for the labs, illness and checkups lists: card or
* list view, a manual order, and (labs only) the sort and the categories.
* list view, a manual order, and (labs only) the sort, the categories and
* their order.
*
* The choices live on the server (`GET/PUT /api/settings/module-list-layout`,
* `User.moduleListLayoutJson`) so they follow a person to every device. Until
@@ -198,6 +199,11 @@ type Translator = (
* Apply one field optimistically (the control has to feel instant), then
* persist it. On failure the cache rolls back and a toast says so; a view or
* sort change confirms with a toast, an order change is its own feedback.
*
* The category order is applied by the server (`GET /api/labs/overview`
* answers `categories` already arranged), so a change to it moves the
* cached overview's categories to the full order the editor sent, at once,
* and then refetches the overview, whose answer is the one that stays.
*/
export async function runSetModuleListPrefs(deps: {
list: ModuleListKey;
@@ -210,6 +216,25 @@ export async function runSetModuleListPrefs(deps: {
if (queryClient.isFetching({ queryKey: key }) > 0) {
await queryClient.cancelQueries({ queryKey: key });
}
const overviewKey = queryKeys.labResultsOverview();
const categoryOrder = patch.categoryOrder;
if (categoryOrder !== undefined) {
await queryClient.cancelQueries({ queryKey: overviewKey });
queryClient.setQueriesData<{ categories?: { key: string }[] }>(
{ queryKey: overviewKey },
(cached) =>
cached?.categories
? {
...cached,
categories: applyOrder(
cached.categories,
categoryOrder,
(category) => category.key,
),
}
: cached,
);
}
const previous = queryClient.getQueryData<ModuleListLayout>(key);
const base = previous ?? resolveModuleListLayout(null);
queryClient.setQueryData<ModuleListLayout>(key, {
@@ -237,6 +262,10 @@ export async function runSetModuleListPrefs(deps: {
} catch (failure) {
queryClient.setQueryData(key, previous);
showErrorToast(t("settings.savingError"), { cause: failure });
} finally {
if (categoryOrder !== undefined) {
void queryClient.invalidateQueries({ queryKey: overviewKey });
}
}
}
@@ -252,6 +281,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
setSortDir: (sortDir: ModuleSortDir) => void;
setGroupBy: (groupBy: ModuleGroupBy) => void;
setGroupsOpen: (groupsOpen: ModuleGroupsOpen) => void;
setCategoryOrder: (categoryOrder: string[]) => void;
} {
const queryClient = useQueryClient();
const { t } = useTranslations();
@@ -283,6 +313,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
categoryOrder: entry.categoryOrder,
};
}
const defaults = moduleListDefaults(list);
@@ -293,6 +324,7 @@ export function useModuleListPrefs(list: ModuleListKey): {
sortDir: entry.sortDir,
groupBy: entry.groupBy,
groupsOpen: entry.groupsOpen,
categoryOrder: entry.categoryOrder,
}
: defaults;
if (!mounted) return fallback;
@@ -328,6 +360,11 @@ export function useModuleListPrefs(list: ModuleListKey): {
[write],
);
const setCategoryOrder = useCallback(
(categoryOrder: string[]) => write({ categoryOrder }),
[write],
);
return {
prefs,
isLoaded,
@@ -336,5 +373,6 @@ export function useModuleListPrefs(list: ModuleListKey): {
setSortDir,
setGroupBy,
setGroupsOpen,
setCategoryOrder,
};
}
+4
View File
@@ -105,6 +105,10 @@ const METRIC_STATUS_MODULE_OWNERS: Partial<Record<MeasurementType, ModuleKey>> =
STRESS_LEVEL: "recovery",
TRAINING_READINESS: "recovery",
CARDIO_RECOVERY: "recovery",
// Blood ketones are read off the same meter as fingerstick glucose and
// belong to the glucose module; they stay off the Coach snapshot, so the
// scope token cannot reach them.
BLOOD_KETONES: "glucose",
// The HRV fallback type. `resolveRichMetricForUser` swaps HRV to its RMSSD
// fallback for an account that only has ring/strap nightly RMSSD rows, so
// leaving this unowned would have let the fallback path serve exactly the
+3
View File
@@ -98,6 +98,7 @@ const STATIC_SURFACE_MODULE = {
// The per-night breathing-disturbance index is a sleep-page signal.
"insights-page:breathing-disturbances": "sleep",
"insights-page:blood-glucose": "glucose",
"insights-page:ketones": "glucose",
"insights-page:workouts": "workouts",
"insights-page:nutrients": "nutrients",
// Medications is surface-gated by design: its data routes stay open so an
@@ -160,6 +161,8 @@ const STATIC_SURFACE_MODULE = {
// ── Dashboard summary keys ──
"summary:SLEEP_DURATION": "sleep",
"summary:BLOOD_GLUCOSE": "glucose",
// Blood ketones sit beside glucose: same meter, same module.
"summary:BLOOD_KETONES": "glucose",
"summary:CARDIO_RECOVERY": "recovery",
// The recovery module owns the resting_hr domain (`measurement-scope.ts`),
// so the server gates the type on it; the form and the dashboard follow.
+12 -3
View File
@@ -11,6 +11,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
cycleFeedForecastStyleEnum,
cycleFeedOptionsPatchSchema,
cycleFeedTitleModeEnum,
} from "@/lib/validations/cycle-feed";
@@ -76,6 +77,9 @@ const feedSummary = z
historyCycles: z.number().int().min(0).max(12),
includeFertile: z.boolean(),
titleMode: cycleFeedTitleModeEnum,
forecastStyle: cycleFeedForecastStyleEnum.describe(
"How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on before the option existed is `BAND`.",
),
})
.meta({
id: "CycleFeedSummary",
@@ -202,7 +206,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Turn the calendar subscription on (#1350)",
description:
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
responses: {
...mintResponses,
"409": {
@@ -215,7 +219,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Change the calendar subscription's options (#1350)",
description:
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.",
"Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. A change saved while the link is being replaced applies to the link that stays live.",
requestBody: {
required: true,
content: { "application/json": { schema: feedOptionsRequest } },
@@ -233,6 +237,11 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "No subscription is on (`cycle.feed.none`).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The link was replaced twice while the change was being saved (`cycle.feed.exists`); nothing changed, and sending it again applies it to the link that is live now.",
content: { "application/json": { schema: errorEnvelope } },
},
...MANAGEMENT_REFUSALS,
...malformedJsonResponse,
...stdResponses,
@@ -271,7 +280,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Replace the calendar subscription's link (#1350)",
description:
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
"One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands; a rotation racing the subscription being turned off answers 404 `cycle.feed.none` and the subscription stays off.",
responses: {
...mintResponses,
"404": {
+1 -1
View File
@@ -171,7 +171,7 @@ export const labOverviewResponse = z
markers: z.array(labOverviewMarker),
categories: z.array(labOverviewCategory).meta({
description:
"The list's categories in display order: catalog panels in catalog order, then the person's own panels by label, then `other`.",
"The list's categories in display order, already arranged for the caller: the default order is catalog panels in catalog order, then the person's own panels by label, then `other`, and the caller's `categoryOrder` (`PUT /api/settings/module-list-layout`, labs list) is applied over it. Render them in this order.",
}),
grouped: z.boolean().meta({
description:
@@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
tags: ["MeasurementReminders"],
summary: "List Vorsorge reminders (v1.17.1)",
description:
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.",
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. A rolling reminder (intervalDays, any length), an rrule reminder whose next occurrence is more than seven days out, and a reminder with no cadence keep their nextDueAt after their reminder is sent, with or without a measurementType: they stay due, then overdue, until satisfied (a matching reading or lab result, or completed), skipped or snoozed. While one stays open the server repeats the reminder every intervalDays local days for a rolling reminder, capped at seven, and every seven local days otherwise. An rrule reminder that recurs within seven days rolls on to its next slot after a delivered reminder.",
responses: {
...recordRefusal(),
"200": {
+21 -2
View File
@@ -696,6 +696,8 @@ const seriesKindEnum = z.enum([
"restingHeartRate",
"heartRateVariability",
"vo2Max",
"ketones",
"glucoseKetoneIndex",
]);
const seriesQuerySchema = z.object({
@@ -747,6 +749,23 @@ const seriesPointSchema = z.object({
.describe(
"v1.19.2 (iOS #34 extension) — per-point MAXIMUM for `kind=pulse`. On an aggregated hourly heart-rate bucket `value` is the hour's average and this is the hour's high; null on a per-sample PULSE row and absent for every other kind.",
),
gki: z
.object({
glucoseId: z.string().describe("The paired glucose reading's id."),
glucoseAt: isoDateTime().describe(
"The paired glucose reading's timestamp.",
),
glucoseMmol: z
.number()
.describe("The paired glucose in mmol/L, one decimal."),
ketonesMmol: z
.number()
.describe("The ketone reading (beta-hydroxybutyrate), mmol/L."),
})
.optional()
.describe(
"#1367 — for `kind=glucoseKetoneIndex` only: the readings the index was computed from. `id` and `at` are the ketone reading's. Absent for every other kind.",
),
});
const seriesResponse = z
@@ -755,7 +774,7 @@ const seriesResponse = z
unit: z
.string()
.describe(
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours); `ketones` is `mmol/L`; `glucoseKetoneIndex` is `ratio`.",
),
points: z.array(seriesPointSchema),
stats: z
@@ -783,7 +802,7 @@ const seriesResponse = z
.meta({
id: "MeasurementsSeriesResponse",
description:
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.",
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets. `glucoseKetoneIndex` (#1367) is derived at read time, never stored: one point per ketone reading above zero that has a glucose reading at the same instant or, failing that, the nearest one within 15 minutes either side; value = glucose (mmol/L, from mg/dL by 18.0182) / ketones (mmol/L), one decimal. Draw it as given; do not recompute it.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
+5
View File
@@ -59,6 +59,11 @@ const moduleListPrefsEntry = z
.describe(
'Which labs categories start open: every one (`expanded`), only those whose `status` is `above` or `below` (`flagged`), or none (`collapsed`). Default "expanded". The other lists ignore it.',
),
categoryOrder: z
.array(z.string())
.describe(
'The person\'s order of the labs categories, as `categories[].key` values of `GET /api/labs/overview`. The server applies it there, so a client renders `categories` as given and never sorts them itself. Keys missing from the live data are ignored; a category missing from the list follows the listed ones in the default order, and "other" left at the end of the list stays last. Default empty (the default order). The other lists ignore it.',
),
saved: z
.boolean()
.describe(
+4 -2
View File
@@ -256,7 +256,7 @@ const syncChangesQuery = z
.literal("now")
.optional()
.describe(
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits two minutes before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in those two minutes (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
),
limit: z.coerce
.number()
@@ -285,7 +285,9 @@ export const syncChangesResponse = z
.describe("Opaque cursor to echo into the next request."),
hasMore: z
.boolean()
.describe("False once the client is caught up as of `serverNow`."),
.describe(
"False once the client is caught up as of `serverNow`. Every row domain that ran dry on a page takes a watermark two minutes before `serverNow` (on the last page of a walk, all of them), so the next pull reads rows changed in that stretch again (a write still in flight may commit a row stamped there) and a domain that stays quiet never ages its cursor past the tombstone horizon; upserts are idempotent.",
),
cursorExpired: z
.boolean()
.describe(
+4 -4
View File
@@ -175,10 +175,11 @@ export function getPRDirection(
if (context.weightGoal === "higher") return PersonalRecordDirection.MAX;
return null;
// Explicitly no PersonalRecord — see comment block above.
// Explicitly no PersonalRecord (see above); ketones follow a clinician's plan.
case "BLOOD_PRESSURE_SYS":
case "BLOOD_PRESSURE_DIA":
case "BLOOD_GLUCOSE":
case "BLOOD_KETONES":
case "BODY_TEMPERATURE":
case "SKIN_TEMPERATURE":
case "PULSE":
@@ -267,9 +268,8 @@ export function getPRDirection(
case "TRAINING_READINESS":
case "ACUTE_TRAINING_LOAD":
// v1.17.1 — Oura coverage. The Sleep Score is a nightly derived composite
// (like the other sleep-quality indices), and the body-temperature
// deviation is a homeostatic signed offset centred on 0 — neither has a
// goal axis, so both stay null like the rest of the derived/homeostatic set.
// and the body-temperature deviation a homeostatic signed offset centred
// on 0: neither has a goal axis, so both stay null like the set above.
case "SLEEP_SCORE":
case "BODY_TEMPERATURE_DEVIATION":
// v1.19.0 — Oura resilience. An ordinal-encoded categorical band
@@ -0,0 +1,50 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/** One point of `kind=glucoseKetoneIndex`, as the server resolved it. */
export interface GlucoseKetoneIndexSeriesPoint {
/** The ketone reading's id. */
id: string;
/** The ketone reading's instant. */
at: string;
/** The index, one decimal. */
value: number;
gki: {
glucoseId: string;
glucoseAt: string;
glucoseMmol: number;
ketonesMmol: number;
};
}
interface GlucoseKetoneIndexSeries {
kind: "glucoseKetoneIndex";
unit: string;
points: GlucoseKetoneIndexSeriesPoint[];
}
/** The whole history the series route serves; the chart narrows it. */
export const GKI_SERIES_DAYS = 3650;
/**
* The glucose-ketone index over the last {@link GKI_SERIES_DAYS} days (#1367).
* The server pairs the readings and computes every index; the card draws them
* as given.
*/
export function useGlucoseKetoneIndexSeries() {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.glucoseKetoneIndexSeries(GKI_SERIES_DAYS),
queryFn: () =>
apiGet<GlucoseKetoneIndexSeries>(
`/api/measurements/series?kind=glucoseKetoneIndex&days=${GKI_SERIES_DAYS}`,
),
staleTime: 60_000,
enabled: isAuthenticated,
});
}
+8
View File
@@ -127,6 +127,14 @@ export const measurementKeys = {
bpTimeOfDay: (days: number) =>
["measurements", "bp-time-of-day", days] as const,
/**
* #1367 — the glucose-ketone index series
* (`GET /api/measurements/series?kind=glucoseKetoneIndex&days=`). Under the
* `["measurements"]` prefix, so a new glucose or ketone reading refreshes it.
*/
glucoseKetoneIndexSeries: (days: number) =>
["measurements", "glucose-ketone-index", days] as const,
/**
* v1.8.5 — bounded recent-timestamp read powering the
* measurement-diversity nudge on an insights category page. Keyed by
@@ -63,9 +63,9 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(91);
expect(MEASUREMENT_LEAF_IDS).toHaveLength(92);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(ALL_LEAF_IDS).toHaveLength(111);
expect(ALL_LEAF_IDS).toHaveLength(112);
});
it("resolves a label for every leaf", () => {
+2
View File
@@ -197,6 +197,7 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
// ── glucose ──
BLOOD_GLUCOSE: "glucose",
BLOOD_KETONES: "glucose",
// ── fenced ──
// Mindful minutes and the State of Mind valence are mental-health
@@ -337,6 +338,7 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
INDOOR_HUMIDITY: "environment",
// Glucose: the raw series and the derived panel.
BLOOD_GLUCOSE: "glucose",
BLOOD_KETONES: "glucose",
GLUCOSE_PANEL: "glucose",
// The screening totals ride the mental-health module as well as being
// fenced in the panel. Item-level answers are never a Measurement, so only
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import {
MODULE_LIST_CATEGORY_ORDER_MAX,
MODULE_LIST_ORDER_MAX,
normaliseModuleListOrder,
resolveModuleListLayout,
storedModuleListLayout,
mergeCategoryOrder,
} from "../module-list-layout";
describe("resolveModuleListLayout", () => {
@@ -16,6 +18,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
expect(layout.lists.illness.view).toBe("cards");
@@ -34,6 +37,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: true,
});
expect(layout.lists.labs.saved).toBe(false);
@@ -50,6 +54,7 @@ describe("resolveModuleListLayout", () => {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
expect(layout.lists.vorsorge.saved).toBe(false);
@@ -70,11 +75,49 @@ describe("category choices", () => {
expect(layout.lists.illness).toMatchObject({
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
saved: false,
});
});
});
describe("category order", () => {
it("keeps a stored category order, deduplicated, and marks the list saved", () => {
const layout = resolveModuleListLayout({
labs: { categoryOrder: ["thyroid", "lipids", "thyroid", 7, "other"] },
});
expect(layout.lists.labs).toMatchObject({
categoryOrder: ["thyroid", "lipids", "other"],
saved: true,
});
});
it("drops a malformed category order and leaves the default", () => {
const layout = resolveModuleListLayout({
labs: { categoryOrder: "lipids" },
});
expect(layout.lists.labs.categoryOrder).toEqual([]);
expect(layout.lists.labs.saved).toBe(false);
});
it("caps the category order on its own limit", () => {
const keys = Array.from(
{ length: MODULE_LIST_CATEGORY_ORDER_MAX + 5 },
(_, i) => `custom:${i}`,
);
const layout = resolveModuleListLayout({ labs: { categoryOrder: keys } });
expect(layout.lists.labs.categoryOrder).toHaveLength(
MODULE_LIST_CATEGORY_ORDER_MAX,
);
});
it("hands every list its own copy of the default", () => {
const layout = resolveModuleListLayout(null);
layout.lists.labs.categoryOrder.push("lipids");
expect(resolveModuleListLayout(null).lists.labs.categoryOrder).toEqual([]);
});
});
describe("normaliseModuleListOrder", () => {
it("keeps strings, drops duplicates, empties and over-long ids", () => {
expect(
@@ -101,3 +144,15 @@ describe("storedModuleListLayout", () => {
).toEqual({ labs: { sortDir: "manual" } });
});
});
describe("mergeCategoryOrder", () => {
it("keeps the place of a saved category that is not on screen", () => {
expect(
mergeCategoryOrder(["lipids", "liver"], ["liver", "thyroid", "lipids"]),
).toEqual(["lipids", "liver", "thyroid"]);
});
it("is the shown order when nothing else was saved", () => {
expect(mergeCategoryOrder(["b", "a"], [])).toEqual(["b", "a"]);
});
});
+46 -3
View File
@@ -14,6 +14,11 @@
* the list under its server-resolved categories (`"category"`) or flat
* (`"none"`, the default); `groupsOpen` says which categories start open:
* all of them, only those with a value outside its range, or none.
* - `categoryOrder`: only the labs list reads it, and only on the server: a
* category-key allow-list `GET /api/labs/overview` applies before it
* answers `categories` (`applyCategoryOrder` in
* `src/lib/labs/marker-category.ts`), so every client receives the
* categories already in the person's order. Same tolerance as `order`.
*
* Null / missing column = the defaults below. The GET never lazy-writes, and
* the PUT stores only the fields a person has actually chosen, so a default
@@ -51,6 +56,29 @@ export type ModuleGroupsOpen = (typeof MODULE_GROUPS_OPEN)[number];
export const MODULE_LIST_ORDER_MAX = 500;
/** Longest id an order entry may carry (cuid and uuid fit comfortably). */
export const MODULE_LIST_ID_MAX = 64;
/**
* Longest category order a list keeps. Ten catalog panels plus "other" plus
* the person's own panels; a longer one is cut, never refused.
*/
export const MODULE_LIST_CATEGORY_ORDER_MAX = 200;
/**
* The category order to save after a move: the categories on screen in
* their new order, then every saved key that is not on screen now, in its
* saved order. A category without readings at the moment (or one of another
* record a guardian arranged) keeps its place in the list instead of being
* dropped by a move that never showed it.
*/
export function mergeCategoryOrder(
shown: readonly string[],
saved: readonly string[],
): string[] {
const onScreen = new Set(shown);
return [...shown, ...saved.filter((key) => !onScreen.has(key))].slice(
0,
MODULE_LIST_CATEGORY_ORDER_MAX,
);
}
export interface ModuleListPrefs {
view: ModuleListView;
@@ -58,6 +86,7 @@ export interface ModuleListPrefs {
sortDir: ModuleSortDir;
groupBy: ModuleGroupBy;
groupsOpen: ModuleGroupsOpen;
categoryOrder: string[];
}
export interface ModuleListPrefsEntry extends ModuleListPrefs {
@@ -76,6 +105,7 @@ const BASE_DEFAULTS: ModuleListPrefs = {
sortDir: "recentDesc",
groupBy: "none",
groupsOpen: "expanded",
categoryOrder: [],
};
/** Labs opens on the compact list, the dense reading table (#40). */
@@ -87,7 +117,11 @@ const LIST_DEFAULTS: Record<ModuleListKey, ModuleListPrefs> = {
export function moduleListDefaults(list: ModuleListKey): ModuleListPrefs {
const defaults = LIST_DEFAULTS[list];
return { ...defaults, order: [...defaults.order] };
return {
...defaults,
order: [...defaults.order],
categoryOrder: [...defaults.categoryOrder],
};
}
export function isModuleListView(value: unknown): value is ModuleListView {
@@ -119,7 +153,10 @@ export function isModuleGroupsOpen(value: unknown): value is ModuleGroupsOpen {
}
/** Keep string ids of a sane length, first occurrence wins, capped. */
export function normaliseModuleListOrder(raw: unknown): string[] | null {
export function normaliseModuleListOrder(
raw: unknown,
max: number = MODULE_LIST_ORDER_MAX,
): string[] | null {
if (!Array.isArray(raw)) return null;
const seen = new Set<string>();
const out: string[] = [];
@@ -129,7 +166,7 @@ export function normaliseModuleListOrder(raw: unknown): string[] | null {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
if (out.length >= MODULE_LIST_ORDER_MAX) break;
if (out.length >= max) break;
}
return out;
}
@@ -148,8 +185,13 @@ export function storedModuleListChoices(
sortDir?: unknown;
groupBy?: unknown;
groupsOpen?: unknown;
categoryOrder?: unknown;
};
const order = normaliseModuleListOrder(blob.order);
const categoryOrder = normaliseModuleListOrder(
blob.categoryOrder,
MODULE_LIST_CATEGORY_ORDER_MAX,
);
return {
...(isModuleListView(blob.view) ? { view: blob.view } : {}),
...(order !== null ? { order } : {}),
@@ -158,6 +200,7 @@ export function storedModuleListChoices(
...(isModuleGroupsOpen(blob.groupsOpen)
? { groupsOpen: blob.groupsOpen }
: {}),
...(categoryOrder !== null ? { categoryOrder } : {}),
};
}
@@ -328,5 +328,11 @@
"display": "Indoor relative humidity",
"unit": "%",
"category": "activity"
},
"BLOOD_KETONES": {
"loinc": "104816-4",
"display": "Beta hydroxybutyrate [Moles/volume] in Blood",
"unit": "mmol/L",
"category": "laboratory"
}
}
+29
View File
@@ -341,6 +341,35 @@ export const SIGNALS: Record<string, SignalDefinition> = {
mcp: true,
},
},
// Blood ketones (#1367). Beta-hydroxybutyrate from a fingerstick meter,
// typed by hand. No band: what a ketone reading should be depends on the
// plan a clinician sets, so the value is shown as recorded. Off the Coach
// snapshot and the correlations, readable over MCP.
BLOOD_KETONES: {
key: "BLOOD_KETONES",
kind: "measurement",
source: { measurementType: "BLOOD_KETONES" },
displayName: "Blood ketones (BHB)",
unit: "mmol/L",
direction: "target-band",
archetype: "physiological-vital",
surfaces: {
detailPage: true,
correlationEligible: false,
coachSnapshot: false,
mcp: true,
},
// LOINC 104816-4 "Beta hydroxybutyrate [Moles/volume] in Blood": the
// quantitative, method-less blood term. A typed value does not say
// whether it came from a strip or a venous draw, and the strip term
// (66441-7) is semi-quantitative, which a numeric mmol/L reading is not.
fhir: {
loinc: "104816-4",
display: "Beta hydroxybutyrate [Moles/volume] in Blood",
unit: "mmol/L",
category: "laboratory",
},
},
WALKING_HEART_RATE_AVERAGE: {
key: "WALKING_HEART_RATE_AVERAGE",
kind: "measurement",
+3 -3
View File
@@ -139,16 +139,16 @@ describe("cursor issue time (sync reset)", () => {
});
describe("encodeCursorAtNow", () => {
it("places every domain a minute back, before any id in that millisecond", () => {
it("places every domain two minutes back, before any id in that millisecond", () => {
const nowMs = 1_790_000_000_123;
const token = encodeCursorAtNow(nowMs);
const cursor = decodeCursor(token);
// A write stamped before the instant but committed after it is still
// ahead of the watermark.
expect(START_NOW_OVERLAP_MS).toBe(60_000);
expect(START_NOW_OVERLAP_MS).toBe(120_000);
for (const domain of SYNC_DOMAINS) {
expect(cursor?.[domain], domain).toEqual({
updatedAtMs: nowMs - 60_000,
updatedAtMs: nowMs - 120_000,
id: "0",
});
}
+35 -1
View File
@@ -14,6 +14,7 @@ import {
decodeCursorIssuedAt,
encodeCursor,
encodeCursorAtNow,
drainedWatermark,
SYNC_DOMAINS,
type SyncCursor,
type SyncDomain,
@@ -110,7 +111,7 @@ function emptyChanges(): SyncChangesBody["changes"] {
}
/**
* `start=now` (#1339): an empty page with a cursor a minute before
* `start=now` (#1339): an empty page with a cursor two minutes before
* `serverNow` in every domain (`encodeCursorAtNow`), for a client that wants changes from now on (the Apple Health
* write-back) without paging through the history first. `hasMore` is false:
* the client is caught up as of `serverNow` by definition.
@@ -233,6 +234,39 @@ export async function pullSyncChanges(input: {
cy.hasMore ||
mm.hasMore;
// A row domain whose page ran dry takes the overlap watermark
// (`drainedWatermark`). On the last page of a walk every one does. On an
// earlier page only one whose watermark has not yet passed the overlap
// line: one that walked into the last stretch on a full page keeps its
// place, because reading that stretch again while it holds more than a
// page, with another domain still paging, would never end. The cycle
// domains are left alone while the module is off, and `measurementMirror`
// keeps its token stamps: re-reading its stretch would send every reading
// of a token whose switch just flipped once more on every pull.
const drained = drainedWatermark(serverNow.getTime());
const rowDomains: Array<[SyncDomain, boolean]> = [
["measurements", m.hasMore],
["mood", md.hasMore],
["intakes", ik.hasMore],
...(cycleEnabled
? ([
["cycleDays", cd.hasMore],
["cycles", cy.hasMore],
] as Array<[SyncDomain, boolean]>)
: []),
];
for (const [domain, domainHasMore] of rowDomains) {
if (domainHasMore) continue;
const before = cursor[domain];
if (
!hasMore ||
before === undefined ||
before.updatedAtMs <= drained.updatedAtMs
) {
nextCursor[domain] = drained;
}
}
annotate({
action: { name: "sync.changes.pull" },
meta: {
+30 -6
View File
@@ -103,13 +103,16 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
}
/**
* How far behind the present a `start=now` cursor is placed. `updatedAt` is
* stamped by the application when the write is built, not when it commits, so
* a transaction in flight at `nowMs` can commit a row stamped before it after
* the cursor was handed out; a watermark at `nowMs` would skip that row for
* good. A minute covers any write the app keeps open.
* How far behind the present a caught-up watermark is placed: a `start=now`
* cursor, and every domain a page has drained. `updatedAt` is stamped by the
* application when the write is built, not when it commits, so a transaction
* in flight can commit a row stamped before a watermark after it was handed
* out; a watermark at the present would skip that row for good. The longest
* interactive transaction that writes served rows (the provider syncs and the
* intake import) times out at 60 s, so two minutes covers any of them with
* room to spare.
*/
export const START_NOW_OVERLAP_MS = 60_000;
export const START_NOW_OVERLAP_MS = 120_000;
/**
* A token positioned just before `nowMs` in every domain (#1339), the same
@@ -129,6 +132,27 @@ export function encodeCursorAtNow(nowMs: number): string {
return encodeCursor(cursor, nowMs);
}
/**
* The watermark for a domain whose page has run dry: `START_NOW_OVERLAP_MS`
* before `nowMs`, whichever way that moves it.
*
* Back, when the last row delivered is newer: a long write (a provider sync
* holds its transaction for seconds) stamps its rows first and commits them
* last, so a quick write committing in between carried the watermark past
* them and the keyset never returned them. Held back, the next pull reads
* that stretch again and the late rows come with it.
*
* Forward, when the last row is older or there is none: the page just read
* everything past the old watermark, so nothing older than the overlap is
* left behind it. A domain that stays quiet would otherwise keep the stamp
* of its last row for good, and once that is older than the tombstone
* horizon every pull answers `cursorExpired` and the client resyncs from
* scratch, again and again.
*/
export function drainedWatermark(nowMs: number): DomainWatermark {
return { updatedAtMs: nowMs - START_NOW_OVERLAP_MS, id: "0" };
}
function parseWatermark(raw: unknown): DomainWatermark | null {
if (typeof raw !== "object" || raw === null) return null;
const u = (raw as { u?: unknown }).u;
+13
View File
@@ -10,6 +10,18 @@ import { z } from "zod/v4";
/** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */
export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]);
/** How a forecast period is drawn. Mirrors `CycleCalendarFeedForecastStyle`. */
export const cycleFeedForecastStyleEnum = z.enum(["EXACT", "MARGIN", "BAND"]);
/**
* The style a newly minted feed starts with. The column's own default is BAND,
* which keeps every feed minted before the option on the events it had; a new
* one starts with the calculated days and their margin drawn apart.
*/
export const NEW_FEED_FORECAST_STYLE: z.infer<
typeof cycleFeedForecastStyleEnum
> = "MARGIN";
/** Forecast cycles ahead. The feed caps itself further by band width. */
export const FEED_HORIZON_MIN = 1;
export const FEED_HORIZON_MAX = 6;
@@ -33,6 +45,7 @@ export const cycleFeedOptionsPatchSchema = z
.optional(),
includeFertile: z.boolean().optional(),
titleMode: cycleFeedTitleModeEnum.optional(),
forecastStyle: cycleFeedForecastStyleEnum.optional(),
})
.strict()
.refine((v) => Object.keys(v).length > 0, {
+2
View File
@@ -171,6 +171,8 @@ export const measurementTypeEnum = z.enum([
"ACUTE_TRAINING_LOAD",
// ── resting energy (#1340), HealthKit `basalEnergyBurned` ──
"BASAL_ENERGY_BURNED",
// ── blood ketones (#1367), beta-hydroxybutyrate in mmol/L ──
"BLOOD_KETONES",
]);
/**
+5
View File
@@ -9,6 +9,7 @@ import { z } from "zod/v4";
import {
MODULE_GROUPS_OPEN,
MODULE_GROUP_BYS,
MODULE_LIST_CATEGORY_ORDER_MAX,
MODULE_LIST_ID_MAX,
MODULE_LIST_KEYS,
MODULE_LIST_ORDER_MAX,
@@ -28,5 +29,9 @@ export const moduleListLayoutPutSchema = z
sortDir: z.enum(MODULE_SORT_DIRS).optional(),
groupBy: z.enum(MODULE_GROUP_BYS).optional(),
groupsOpen: z.enum(MODULE_GROUPS_OPEN).optional(),
categoryOrder: z
.array(z.string().min(1).max(MODULE_LIST_ID_MAX))
.max(MODULE_LIST_CATEGORY_ORDER_MAX)
.optional(),
})
.strict();