From 0cb877e0a26cdd0cd69d17929700acaaacf9e49d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:22:26 +0200
Subject: [PATCH 01/12] fix(targets): hold below a default band for load
measures and above the band for one-sided measures
---
src/lib/openapi/routes/trend-verdict.ts | 2 +-
.../targets/__tests__/trend-verdict.test.ts | 48 +++++++++++++++++++
src/lib/targets/trend-verdict.ts | 46 +++++++++++++++++-
3 files changed, 94 insertions(+), 2 deletions(-)
diff --git a/src/lib/openapi/routes/trend-verdict.ts b/src/lib/openapi/routes/trend-verdict.ts
index c8772683e..fcf5862ee 100644
--- a/src/lib/openapi/routes/trend-verdict.ts
+++ b/src/lib/openapi/routes/trend-verdict.ts
@@ -13,7 +13,7 @@ export const trendVerdict = z
direction: z
.enum(["up-good", "up-bad", "neutral", "hold"])
.describe(
- "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside its band so staying is progress, `neutral` no judgement.",
+ "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is never worse).",
),
targetPosition: z
.enum(["below", "inside", "above"])
diff --git a/src/lib/targets/__tests__/trend-verdict.test.ts b/src/lib/targets/__tests__/trend-verdict.test.ts
index 2051265c4..23e88bca6 100644
--- a/src/lib/targets/__tests__/trend-verdict.test.ts
+++ b/src/lib/targets/__tests__/trend-verdict.test.ts
@@ -36,6 +36,9 @@ describe("resolveTrendVerdict against the person's own target", () => {
expect(trendSentimentOf(4, v)).toBe("negative");
});
+ // A target the person set themselves is a goal in both directions: below
+ // it, climbing back up is what they asked for. This stays `up-good` even
+ // for the load measures, unlike the profile-default case further down.
it("calls the same rise below the target progress", () => {
const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP);
expect(v).toEqual({
@@ -112,6 +115,28 @@ describe("resolveTrendVerdict against a profile default", () => {
expect(verdict("BODY_FAT", 30).basis).toBe("metric_default");
});
+ // A default band's lower edge is a population convention, not a goal. A
+ // low blood pressure, resting pulse or body fat against a band nobody chose
+ // holds: its fall is never painted as a setback.
+ it("holds below a default band for the load measures", () => {
+ expect(verdict("BLOOD_PRESSURE_SYS", 108, null, MAN_41)).toEqual({
+ direction: "hold",
+ targetPosition: "below",
+ basis: "profile_default",
+ });
+ expect(verdict("BLOOD_PRESSURE_DIA", 62, null, MAN_41).direction).toBe(
+ "hold",
+ );
+ for (const key of ["PULSE", "RESTING_HEART_RATE"]) {
+ const v = verdict(key, 45, null, MAN_41);
+ expect(v).toMatchObject({ direction: "hold", targetPosition: "below" });
+ expect(trendSentimentOf(-3, v)).toBe("neutral");
+ }
+ expect(
+ verdict("BODY_FAT", 5, null, { ...MAN_41, gender: "MALE" }),
+ ).toMatchObject({ direction: "hold", targetPosition: "below" });
+ });
+
it("prefers the person's own target over the profile default", () => {
expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe(
"personal_target",
@@ -119,6 +144,29 @@ describe("resolveTrendVerdict against a profile default", () => {
});
});
+describe("one-sided measures", () => {
+ it("never calls steps above the own target a setback", () => {
+ const own = { ACTIVITY_STEPS: { min: 8000, max: 12_000 } };
+ const v = verdict("ACTIVITY_STEPS", 15_000, own);
+ expect(v).toEqual({
+ direction: "hold",
+ targetPosition: "above",
+ basis: "personal_target",
+ });
+ expect(trendSentimentOf(2000, v)).not.toBe("negative");
+ expect(trendSentimentOf(-2000, v)).not.toBe("negative");
+ expect(verdict("ACTIVITY_STEPS", 5000, own).direction).toBe("up-good");
+ });
+
+ it("never calls oxygen saturation above the own target a setback", () => {
+ const own = { OXYGEN_SATURATION: { min: 92, max: 96 } };
+ expect(verdict("OXYGEN_SATURATION", 98, own)).toMatchObject({
+ direction: "hold",
+ targetPosition: "above",
+ });
+ });
+});
+
describe("resolveTrendVerdict metric defaults", () => {
it.each([
["ACTIVITY_STEPS", "up-good"],
diff --git a/src/lib/targets/trend-verdict.ts b/src/lib/targets/trend-verdict.ts
index 22ca94065..a0a312799 100644
--- a/src/lib/targets/trend-verdict.ts
+++ b/src/lib/targets/trend-verdict.ts
@@ -17,6 +17,17 @@
* below the band → a rise is progress (`up-good`)
* above the band → a fall is progress (`up-bad`)
* inside the band → holding is progress (`hold`)
+ * - two exceptions to "below / above":
+ * a low reading of a load measure (blood pressure, resting pulse, body
+ * fat) against a band nobody chose is `hold`, not `up-good`. A
+ * default band's lower edge is a population convention, not a goal:
+ * a well-trained resting pulse of 48 or a systolic of 108 is not
+ * something to climb out of, and painting its fall orange would tell
+ * the person to raise it. Only a target the person set themselves
+ * turns "below" into "a rise is progress".
+ * a one-sided measure (steps, oxygen saturation) above its band is
+ * `hold`, never `up-bad`: more steps than the target, or a saturation
+ * above it, is never a setback.
* - no band, or no value to place: the metric's own default direction.
*
* Weight keeps its own rule (`weight-trend.ts`): only a target the person set
@@ -200,6 +211,39 @@ export function placeAgainstBand(
return "inside";
}
+/**
+ * Load measures whose default band's lower edge is no goal (see the module
+ * comment): below a profile-default band they hold rather than climb.
+ */
+const LOW_IS_NOT_A_SETBACK: ReadonlySet = new Set([
+ "BLOOD_PRESSURE_SYS",
+ "BLOOD_PRESSURE_DIA",
+ "PULSE",
+ "BODY_FAT",
+]);
+
+/** Measures where more is never worse: above the band they hold. */
+const ONE_SIDED_UPWARD: ReadonlySet = new Set([
+ "ACTIVITY_STEPS",
+ "OXYGEN_SATURATION",
+]);
+
+function directionFor(
+ metric: ThresholdMetric,
+ position: TrendTargetPosition,
+ basis: Exclude,
+): TrendDirectionSentiment {
+ if (
+ position === "below" &&
+ basis === "profile_default" &&
+ LOW_IS_NOT_A_SETBACK.has(metric)
+ ) {
+ return "hold";
+ }
+ if (position === "above" && ONE_SIDED_UPWARD.has(metric)) return "hold";
+ return DIRECTION_BY_POSITION[position];
+}
+
const DIRECTION_BY_POSITION: Record<
TrendTargetPosition,
TrendDirectionSentiment
@@ -246,7 +290,7 @@ export function resolveTrendVerdict(
toUnit ? toUnit(reference) : reference,
);
return {
- direction: DIRECTION_BY_POSITION[position],
+ direction: directionFor(metric, position, personal.basis),
targetPosition: position,
basis: personal.basis,
};
From 86113848b541ffa07ac6566062e258bc93f2b778 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:23:06 +0200
Subject: [PATCH 02/12] fix(medications): refresh the episode and document
details after filing a link
---
.../medication-related-section.test.tsx | 58 +++++++++++++++++++
.../detail/medication-related-section.tsx | 6 +-
2 files changed, 63 insertions(+), 1 deletion(-)
create mode 100644 src/components/medications/__tests__/medication-related-section.test.tsx
diff --git a/src/components/medications/__tests__/medication-related-section.test.tsx b/src/components/medications/__tests__/medication-related-section.test.tsx
new file mode 100644
index 000000000..f03dcb3ed
--- /dev/null
+++ b/src/components/medications/__tests__/medication-related-section.test.tsx
@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+const captured = vi.hoisted(() => ({
+ onSaved: undefined as (() => void) | undefined,
+}));
+
+vi.mock("@/components/links/related-records", () => ({
+ RelatedRecordsList: () => null,
+ RelationLinkPickers: (props: { onSaved: () => void }) => {
+ captured.onSaved = props.onSaved;
+ return null;
+ },
+}));
+
+vi.mock("@/hooks/use-record-capabilities", () => ({
+ useRecordCapabilities: () => ({ inSharedRecord: false }),
+}));
+
+import { MedicationRelatedSection } from "@/components/medications/detail/medication-related-section";
+
+/**
+ * A link filed from the medication is listed on the other end too, so a save
+ * must stale every detail that reads it back: the medication itself, the
+ * episode, the document and the visit.
+ */
+describe("MedicationRelatedSection", () => {
+ it("invalidates every end a saved link is read back on", () => {
+ const qc = new QueryClient();
+ const spy = vi.spyOn(qc, "invalidateQueries");
+ renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+ expect(captured.onSaved).toBeTypeOf("function");
+ captured.onSaved?.();
+ const keys = spy.mock.calls.map((call) => call[0]?.queryKey);
+ expect(keys).toEqual(
+ expect.arrayContaining([
+ queryKeys.medicationDetail("med-1"),
+ queryKeys.illness(),
+ queryKeys.documents(),
+ queryKeys.encounters(),
+ ]),
+ );
+ });
+});
diff --git a/src/components/medications/detail/medication-related-section.tsx b/src/components/medications/detail/medication-related-section.tsx
index a2b487a2d..ff5774539 100644
--- a/src/components/medications/detail/medication-related-section.tsx
+++ b/src/components/medications/detail/medication-related-section.tsx
@@ -46,9 +46,13 @@ export function MedicationRelatedSection({
related={related}
anchor={startsOn}
onSaved={() => {
- // The other end lists the medication too.
+ // The other end lists the medication too: every kind the
+ // pickers file (episode, document, visit) reads it back on its
+ // own detail, so each one's prefix goes stale.
for (const queryKey of [
queryKeys.medicationDetail(medicationId),
+ queryKeys.illness(),
+ queryKeys.documents(),
queryKeys.encounters(),
]) {
void qc.invalidateQueries({ queryKey });
From b3dcd428f6c55946e72352840f841ee622b3340f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:23:59 +0200
Subject: [PATCH 03/12] fix(settings): stack the AI usage estimate on narrow
screens and read readiness from the server
---
.../ai/__tests__/ai-usage-card.test.tsx | 33 ++++++++++
src/components/settings/ai/ai-usage-card.tsx | 60 +++++++++++--------
2 files changed, 68 insertions(+), 25 deletions(-)
diff --git a/src/components/settings/ai/__tests__/ai-usage-card.test.tsx b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx
index 99cf881fd..bddf99e48 100644
--- a/src/components/settings/ai/__tests__/ai-usage-card.test.tsx
+++ b/src/components/settings/ai/__tests__/ai-usage-card.test.tsx
@@ -100,6 +100,39 @@ describe("AiUsageCard", () => {
expect(html).not.toContain('data-slot="ai-usage-use-default"');
});
+ it("follows the server's estimates, not a day threshold of its own", () => {
+ // Two measured days, but the server already published estimates: the
+ // card shows the measured line because the server said it may.
+ const ready = render({ ...CAPPED, measuredDays: 2 });
+ expect(ready).toContain(
+ "Measured: 100,000 tokens a day over the last 2 days",
+ );
+ // Seven days, but the server withheld every estimate: no measured line.
+ const withheld = render({
+ ...CAPPED,
+ options: CAPPED.options.map((o) => ({
+ ...o,
+ estimatedTokensPerDay: null,
+ })),
+ });
+ expect(withheld).toContain(
+ "An estimate per level appears after three days",
+ );
+ });
+
+ it("stacks the estimate under the level on a narrow screen", () => {
+ const html = render(CAPPED);
+ expect(html).toMatch(
+ /class="[^"]*flex-col[^"]*sm:flex-row[^"]*"[^>]*>\s*/,
+ );
+ expect(html).toMatch(
+ /data-slot="ai-usage-estimate" class="[^"]*sm:shrink-0/,
+ );
+ expect(html).not.toMatch(
+ /data-slot="ai-usage-estimate" class="(?:(?!sm:)[^"])*\bshrink-0/,
+ );
+ });
+
it("offers the way back to the default when a choice is stored", () => {
const html = render({
...CAPPED,
diff --git a/src/components/settings/ai/ai-usage-card.tsx b/src/components/settings/ai/ai-usage-card.tsx
index a7817ab78..9075b48bf 100644
--- a/src/components/settings/ai/ai-usage-card.tsx
+++ b/src/components/settings/ai/ai-usage-card.tsx
@@ -70,6 +70,12 @@ export function AiUsageCard() {
const editable = provider.canConfigure && !save.isPending;
const tokens = (value: number) => new Intl.NumberFormat(locale).format(value);
const levelName = (level: AiUsageLevel) => t(LEVEL_KEYS[level].label);
+ // Whether the history suffices for an estimate is the server's call: it
+ // publishes the per-level estimates only once it does. The card reads that
+ // decision instead of repeating its day threshold.
+ const estimatesReady = usage.options.some(
+ (option) => option.estimatedTokensPerDay !== null,
+ );
const allowedLevels = usage.options
.filter((option) => option.allowed)
.map((option) => option.level);
@@ -144,31 +150,35 @@ export function AiUsageCard() {
) : null}
-
-
- {levelName(option.level)}
-
-
- {t(LEVEL_KEYS[option.level].hint)}
-
- {!option.allowed ? (
-
- {t("settings.ai.usage.limited")}
+ {/* Stacked under 640 px so the estimate never squeezes the
+ level's name and hint into a sliver; side by side above. */}
+
+
+
+ {levelName(option.level)}
- ) : null}
-
-
- {option.estimatedTokensPerDay === null
- ? null
- : t("settings.ai.usage.estimate", {
- tokens: tokens(option.estimatedTokensPerDay),
- })}
+
+ {t(LEVEL_KEYS[option.level].hint)}
+
+ {!option.allowed ? (
+
+ {t("settings.ai.usage.limited")}
+
+ ) : null}
+
+
+ {option.estimatedTokensPerDay === null
+ ? null
+ : t("settings.ai.usage.estimate", {
+ tokens: tokens(option.estimatedTokensPerDay),
+ })}
+
);
@@ -179,7 +189,7 @@ export function AiUsageCard() {
data-slot="ai-usage-measured"
className="text-muted-foreground text-xs"
>
- {usage.measuredTokensPerDay === null || usage.measuredDays < 3
+ {usage.measuredTokensPerDay === null || !estimatesReady
? t("settings.ai.usage.noEstimate")
: t("settings.ai.usage.measured", {
tokens: tokens(usage.measuredTokensPerDay),
From d66d3969cd415b30a234de961afbc3d27627e049 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:28:53 +0200
Subject: [PATCH 04/12] fix(records): show the screening hints when one applies
without a smoking answer
---
.../__tests__/screening-hints-card.test.tsx | 76 +++++++++++++++++++
.../records/screening-hints-card.tsx | 13 +++-
2 files changed, 86 insertions(+), 3 deletions(-)
create mode 100644 src/components/records/__tests__/screening-hints-card.test.tsx
diff --git a/src/components/records/__tests__/screening-hints-card.test.tsx b/src/components/records/__tests__/screening-hints-card.test.tsx
new file mode 100644
index 000000000..029817595
--- /dev/null
+++ b/src/components/records/__tests__/screening-hints-card.test.tsx
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { ScreeningHintsCard } from "@/components/records/screening-hints-card";
+import { I18nProvider } from "@/lib/i18n/context";
+import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances";
+import type { ScreeningHint } from "@/lib/profile/life-derived";
+import { queryKeys } from "@/lib/query-keys";
+
+function hint(
+ id: ScreeningHint["id"],
+ status: ScreeningHint["status"],
+): ScreeningHint {
+ return {
+ id,
+ status,
+ reasonKeys: [],
+ region: "DE",
+ sourceLabel: "source",
+ sourceUrl: "https://example.org/",
+ };
+}
+
+function render(tobaccoFacts: number, hints: ScreeningHint[]): string {
+ const dto = {
+ sections: [
+ {
+ key: "tobacco",
+ facts: Array.from({ length: tobaccoFacts }, () => ({})),
+ },
+ ],
+ derived: { screeningHints: hints },
+ } as unknown as LifeCircumstancesDto;
+ const client = new QueryClient();
+ client.setQueryData(queryKeys.lifeCircumstances(), dto);
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+describe("ScreeningHintsCard", () => {
+ it("stays away while nothing applies and smoking is unanswered", () => {
+ expect(
+ render(0, [
+ hint("lung_ldct", "unknown"),
+ hint("aaa_ultrasound", "not_eligible"),
+ ]),
+ ).toBe("");
+ });
+
+ // The German abdominal-aorta ultrasound applies to every man from 65,
+ // whether he ever smoked or not: the hint must reach him without a
+ // smoking answer.
+ it("shows a hint that applies without a smoking answer", () => {
+ const html = render(0, [
+ hint("lung_ldct", "unknown"),
+ hint("aaa_ultrasound", "suggested"),
+ ]);
+ expect(html).toContain('data-slot="screening-hints-card"');
+ expect(html).toContain('data-slot="screening-hint-aaa_ultrasound"');
+ });
+
+ it("shows once smoking is answered", () => {
+ expect(
+ render(1, [
+ hint("lung_ldct", "not_eligible"),
+ hint("aaa_ultrasound", "not_eligible"),
+ ]),
+ ).toContain('data-slot="screening-hints-card"');
+ });
+});
diff --git a/src/components/records/screening-hints-card.tsx b/src/components/records/screening-hints-card.tsx
index e470a4bd4..76b7859c1 100644
--- a/src/components/records/screening-hints-card.tsx
+++ b/src/components/records/screening-hints-card.tsx
@@ -7,8 +7,10 @@
* source and country. A hint, never a reminder: nothing is scheduled here,
* and the card says so. Resolved on the server; rendered as delivered.
*
- * The card stays away until the person has said something about smoking,
- * because every hint would otherwise read "not enough information".
+ * The card stays away until it has something to say: the person has said
+ * something about smoking, or a hint already applies without it (the German
+ * abdominal-aorta ultrasound is offered to every man from 65, smoker or not).
+ * Before either, every hint would read "not enough information".
*/
import { useQuery } from "@tanstack/react-query";
import { Stethoscope } from "lucide-react";
@@ -29,7 +31,12 @@ export function ScreeningHintsCard() {
});
const data = query.data;
const tobacco = data?.sections.find((section) => section.key === "tobacco");
- if (!data || !tobacco || tobacco.facts.length === 0) return null;
+ if (!data) return null;
+ const answeredTobacco = (tobacco?.facts.length ?? 0) > 0;
+ const anySuggested = data.derived.screeningHints.some(
+ (hint) => hint.status === "suggested",
+ );
+ if (!answeredTobacco && !anySuggested) return null;
return (
From 8ba4d1b0d06ff8e6240c355edd175aac31f3274e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:31:33 +0200
Subject: [PATCH 05/12] fix(nutrients): name the form an upper limit covers and
phrase it per locale
---
messages/de.json | 10 ++-
messages/en.json | 10 ++-
messages/es.json | 10 ++-
messages/fr.json | 10 ++-
messages/it.json | 10 ++-
messages/ko.json | 10 ++-
messages/pl.json | 10 ++-
.../micronutrients-upper-limit.test.tsx | 74 +++++++++++++++++++
.../nutrients/micronutrients-card.tsx | 3 +
src/components/insights/nutrients/types.ts | 3 +
src/lib/mcp/tools.ts | 20 ++++-
src/lib/nutrients/__tests__/catalog.test.ts | 6 ++
src/lib/nutrients/catalog.ts | 52 +++++++++----
src/lib/validations/nutrients.ts | 12 ++-
14 files changed, 208 insertions(+), 32 deletions(-)
create mode 100644 src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx
diff --git a/messages/de.json b/messages/de.json
index a9c1f83c5..421fcb35c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert",
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Über der tolerierbaren Höchstmenge von {value} {unit} pro Tag ({source}).",
- "aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge von {value} {unit} pro Tag für Präparate ({source})."
+ "aboveUpperLimit": "Über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag ({source}).",
+ "aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag für Präparate ({source}).",
+ "upperLimitForm": {
+ "retinol": "Die Höchstmenge gilt nur für vorgeformtes Vitamin A (Retinol): Stammt die Menge aus Beta-Carotin, gilt sie nicht.",
+ "folic_acid": "Die Höchstmenge gilt für Folsäure aus Präparaten und angereicherten Lebensmitteln, nicht für das Folat in gewöhnlicher Nahrung.",
+ "magnesium_salts": "Die Höchstmenge gilt für leicht lösliche Magnesiumsalze in Präparaten, nicht für das Magnesium aus der Nahrung.",
+ "alpha_tocopherol": "Die Höchstmenge ist für Alpha-Tocopherol festgelegt."
+ }
},
"sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel. Die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme.",
"units": {
diff --git a/messages/en.json b/messages/en.json
index ca6175a86..b80e0a938 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "No micronutrient data synced yet",
"loadError": "Your nutrient data could not be loaded",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Above the tolerable upper intake level of {value} {unit} a day ({source}).",
- "aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level of {value} {unit} a day for supplements ({source})."
+ "aboveUpperLimit": "Above the tolerable upper intake level for adults of {value} {unit} a day ({source}).",
+ "aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level for adults of {value} {unit} a day for supplements ({source}).",
+ "upperLimitForm": {
+ "retinol": "The level covers preformed vitamin A (retinol) only: if the amount comes from beta-carotene, it does not apply.",
+ "folic_acid": "The level covers folic acid from supplements and fortified foods, not the folate in ordinary food.",
+ "magnesium_salts": "The level covers readily dissociable magnesium salts in supplements, not the magnesium in food.",
+ "alpha_tocopherol": "The level is set for alpha-tocopherol."
+ }
},
"sparseNote": "A low number here usually means sparse logging, not a deficiency. Apple Health day totals reflect what your apps logged, not total dietary intake.",
"units": {
diff --git a/messages/es.json b/messages/es.json
index 8b39b0e3f..bb81a534a 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados",
"loadError": "No se pudieron cargar tus datos de nutrientes",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Por encima del nivel máximo tolerable de {value} {unit} al día ({source}).",
- "aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable de {value} {unit} al día para suplementos ({source})."
+ "aboveUpperLimit": "Por encima del nivel máximo tolerable para adultos de {value} {unit} al día ({source}).",
+ "aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable para adultos de {value} {unit} al día para suplementos ({source}).",
+ "upperLimitForm": {
+ "retinol": "El nivel solo se aplica a la vitamina A preformada (retinol): si la cantidad procede de betacaroteno, no se aplica.",
+ "folic_acid": "El nivel se aplica al ácido fólico de suplementos y alimentos enriquecidos, no al folato de los alimentos habituales.",
+ "magnesium_salts": "El nivel se aplica a las sales de magnesio fácilmente disociables de los suplementos, no al magnesio de los alimentos.",
+ "alpha_tocopherol": "El nivel está fijado para el alfa-tocoferol."
+ }
},
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total.",
"units": {
diff --git a/messages/fr.json b/messages/fr.json
index 92e11b7cd..96777e4a8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant",
"loadError": "Tes données de nutriments n'ont pas pu être chargées",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité de {value} {unit} par jour ({source}).",
- "aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité de {value} {unit} par jour pour les compléments ({source})."
+ "aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité pour les adultes de {value} {unit} par jour ({source}).",
+ "aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité pour les adultes de {value} {unit} par jour pour les compléments ({source}).",
+ "upperLimitForm": {
+ "retinol": "La limite ne concerne que la vitamine A préformée (rétinol) : si la quantité provient du bêta-carotène, elle ne s'applique pas.",
+ "folic_acid": "La limite concerne l'acide folique des compléments et des aliments enrichis, pas les folates de l'alimentation courante.",
+ "magnesium_salts": "La limite concerne les sels de magnésium facilement dissociables des compléments, pas le magnésium des aliments.",
+ "alpha_tocopherol": "La limite est fixée pour l'alpha-tocophérol."
+ }
},
"sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence : les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total.",
"units": {
diff --git a/messages/it.json b/messages/it.json
index c25858eae..4acd04e2f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato",
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Sopra il livello massimo tollerabile di {value} {unit} al giorno ({source}).",
- "aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile di {value} {unit} al giorno per gli integratori ({source})."
+ "aboveUpperLimit": "Sopra il livello massimo tollerabile per gli adulti di {value} {unit} al giorno ({source}).",
+ "aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile per gli adulti di {value} {unit} al giorno per gli integratori ({source}).",
+ "upperLimitForm": {
+ "retinol": "Il livello vale solo per la vitamina A preformata (retinolo): se la quantità proviene dal beta-carotene, non si applica.",
+ "folic_acid": "Il livello vale per l'acido folico da integratori e alimenti arricchiti, non per i folati del cibo comune.",
+ "magnesium_salts": "Il livello vale per i sali di magnesio facilmente dissociabili negli integratori, non per il magnesio degli alimenti.",
+ "alpha_tocopherol": "Il livello è fissato per l'alfa-tocoferolo."
+ }
},
"sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza: i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale.",
"units": {
diff --git a/messages/ko.json b/messages/ko.json
index 56230ec63..1bfd80398 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요",
"loadError": "영양소 데이터를 불러오지 못했어요",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
- "aboveUpperLimitSupplemental": "보충제로 섭취한 양이 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source})."
+ "aboveUpperLimit": "성인 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
+ "aboveUpperLimitSupplemental": "보충제로 섭취한 양이 성인 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
+ "upperLimitForm": {
+ "retinol": "이 상한은 기성 비타민 A(레티놀)에만 적용돼요. 베타카로틴에서 온 양이라면 적용되지 않아요.",
+ "folic_acid": "이 상한은 보충제와 강화식품의 엽산에 적용되며, 일반 음식의 엽산염에는 적용되지 않아요.",
+ "magnesium_salts": "이 상한은 보충제의 쉽게 해리되는 마그네슘염에 적용되며, 음식의 마그네슘에는 적용되지 않아요.",
+ "alpha_tocopherol": "이 상한은 알파토코페롤 기준이에요."
+ }
},
"sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요.",
"units": {
diff --git a/messages/pl.json b/messages/pl.json
index 0a5590b85..8f35637a7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -11873,8 +11873,14 @@
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach",
"loadError": "Nie udało się wczytać danych o składnikach odżywczych",
"sourcePart": "{source} {value} {unit}",
- "aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia {value} {unit} dziennie ({source}).",
- "aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom {value} {unit} dziennie dla suplementów ({source})."
+ "aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia dla dorosłych {value} {unit} dziennie ({source}).",
+ "aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom dla dorosłych {value} {unit} dziennie dla suplementów ({source}).",
+ "upperLimitForm": {
+ "retinol": "Poziom dotyczy tylko gotowej witaminy A (retinolu): jeśli ilość pochodzi z beta-karotenu, nie ma zastosowania.",
+ "folic_acid": "Poziom dotyczy kwasu foliowego z suplementów i żywności wzbogacanej, nie folianów ze zwykłego jedzenia.",
+ "magnesium_salts": "Poziom dotyczy łatwo dysocjujących soli magnezu w suplementach, nie magnezu z jedzenia.",
+ "alpha_tocopherol": "Poziom ustalono dla alfa-tokoferolu."
+ }
},
"sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór. Dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie.",
"units": {
diff --git a/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx b/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx
new file mode 100644
index 000000000..53e25e3cb
--- /dev/null
+++ b/src/components/insights/nutrients/__tests__/micronutrients-upper-limit.test.tsx
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog";
+
+import type { NutrientOverviewRow } from "../types";
+
+const state = vi.hoisted(() => ({ rows: [] as NutrientOverviewRow[] }));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: { nutrients: state.rows },
+ isLoading: false,
+ isError: false,
+ refetch: () => undefined,
+ }),
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: { gender: "MALE" } }),
+}));
+
+import { MicronutrientsCard } from "../micronutrients-card";
+
+function render(rows: NutrientOverviewRow[], locale: "en" | "de" = "en") {
+ state.rows = rows;
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+function row(nutrient: "vitamin_a" | "vitamin_d"): NutrientOverviewRow {
+ return {
+ nutrient,
+ unit: "ug",
+ latestDay: "2026-10-15",
+ latestAmount: 4000,
+ daysWithData: 3,
+ upperLimit: NUTRIENT_CATALOG[nutrient].upperLimit,
+ latestAboveUpperLimit: true,
+ };
+}
+
+/**
+ * The upper limit of vitamin A is set for preformed retinol; beta-carotene
+ * does not count toward it. The amount on file cannot say which form it came
+ * in, so the note says what the level covers, in the reader's language, and
+ * the citation stays a bare citation.
+ */
+describe("MicronutrientsCard upper-limit note", () => {
+ it("says the vitamin A level covers retinol only", () => {
+ const html = render([row("vitamin_a")]);
+ expect(html).toContain('data-slot="nutrients-upper-limit-note"');
+ expect(html).toContain("preformed vitamin A (retinol) only");
+ expect(html).toContain("beta-carotene");
+ expect(html).toContain("(EFSA UL overview 2024 (SCF 2002))");
+ });
+
+ it("phrases the note in the reader's language, not the citation's", () => {
+ const html = render([row("vitamin_a")], "de");
+ expect(html).toContain("Höchstmenge für Erwachsene");
+ expect(html).toContain("Beta-Carotin");
+ expect(html).not.toContain("adults");
+ });
+
+ it("adds no form sentence where the level covers the nutrient as such", () => {
+ const html = render([row("vitamin_d")]);
+ expect(html).toContain('data-slot="nutrients-upper-limit-note"');
+ expect(html).not.toContain("The level covers");
+ });
+});
diff --git a/src/components/insights/nutrients/micronutrients-card.tsx b/src/components/insights/nutrients/micronutrients-card.tsx
index 70bbaf973..9ddd76809 100644
--- a/src/components/insights/nutrients/micronutrients-card.tsx
+++ b/src/components/insights/nutrients/micronutrients-card.tsx
@@ -131,6 +131,9 @@ function UpperLimitNote({
source: limit.source,
},
)}
+ {limit.form
+ ? ` ${t(`nutrients.micronutrients.upperLimitForm.${limit.form}`)}`
+ : null}
);
diff --git a/src/components/insights/nutrients/types.ts b/src/components/insights/nutrients/types.ts
index ca7848aa5..e59289586 100644
--- a/src/components/insights/nutrients/types.ts
+++ b/src/components/insights/nutrients/types.ts
@@ -37,6 +37,9 @@ export interface NutrientUpperLimitDto {
value: number;
unit: string;
scope: "total" | "supplemental";
+ /** The chemical form the level covers; `null` for the nutrient as such. */
+ form?:
+ "retinol" | "folic_acid" | "magnesium_salts" | "alpha_tocopherol" | null;
source: string;
}
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index fa3d851d8..d6e131788 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -62,7 +62,10 @@ import {
NUTRIENT_LABELS,
type NutrientsDailyResult,
} from "@/lib/mcp/nutrients-read";
-import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
+import {
+ NUTRIENT_CODES,
+ type NutrientUpperLimit,
+} from "@/lib/nutrients/catalog";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
@@ -371,6 +374,18 @@ function plainClinicalText(
return `${head}${band}`.trim();
}
+/** What the EFSA level covers where it is narrower than the nutrient. */
+const UPPER_LIMIT_FORM_NOTE: Record<
+ NonNullable,
+ string
+> = {
+ retinol:
+ " (preformed vitamin A, retinol, only; beta-carotene does not count toward it)",
+ folic_acid: " (folic acid from supplements and fortified foods)",
+ magnesium_salts: " (readily dissociable magnesium salts in supplements)",
+ alpha_tocopherol: " (alpha-tocopherol)",
+};
+
/**
* Render a `fetch` result for a nutrient code as a plain-text citation
* sentence: the most recent non-zero day's total plus the resolved EFSA
@@ -407,8 +422,9 @@ function plainNutrientText(
if (limit && latestDay?.aboveUpperLimit) {
const share =
limit.scope === "supplemental" ? " for the supplement share" : "";
+ const form = limit.form ? UPPER_LIMIT_FORM_NOTE[limit.form] : "";
parts.push(
- `that day is above the EFSA upper intake level of ${round1(limit.value)}${unit}${share}`,
+ `that day is above the EFSA upper intake level of ${round1(limit.value)}${unit}${share}${form}`,
);
}
return parts.length > 0
diff --git a/src/lib/nutrients/__tests__/catalog.test.ts b/src/lib/nutrients/__tests__/catalog.test.ts
index 33709a6d0..37b6911b7 100644
--- a/src/lib/nutrients/__tests__/catalog.test.ts
+++ b/src/lib/nutrients/__tests__/catalog.test.ts
@@ -269,10 +269,16 @@ describe("upper intake levels (v1.43)", () => {
expect(def.upperLimit.unit, def.code).toBe(def.unit);
expect(def.upperLimit.value).toBeGreaterThan(0);
expect(def.upperLimit.source).toContain("EFSA");
+ // A bare citation: what the level covers is `form`, phrased per locale.
+ expect(def.upperLimit.source, def.code).not.toMatch(/adults|:/);
}
expect(NUTRIENT_CATALOG.vitamin_d.upperLimit?.value).toBe(100);
expect(NUTRIENT_CATALOG.magnesium.upperLimit?.scope).toBe("supplemental");
expect(NUTRIENT_CATALOG.vitamin_c.upperLimit).toBeNull();
+ // Vitamin A's level is set for preformed retinol; beta-carotene does not
+ // count toward it.
+ expect(NUTRIENT_CATALOG.vitamin_a.upperLimit?.form).toBe("retinol");
+ expect(NUTRIENT_CATALOG.vitamin_d.upperLimit?.form).toBeNull();
expect(NUTRIENT_CATALOG.dha.upperLimit).toBeNull();
});
diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts
index 0efe0f096..9aab97e8e 100644
--- a/src/lib/nutrients/catalog.ts
+++ b/src/lib/nutrients/catalog.ts
@@ -130,9 +130,22 @@ export interface NutrientUpperLimit {
value: number;
unit: NutrientUnit;
scope: "total" | "supplemental";
+ /**
+ * The chemical form the level is set for, where it is narrower than the
+ * nutrient: vitamin A's level covers preformed retinol only (beta-carotene
+ * does not count), folate's the folic acid, magnesium's the dissociable
+ * salts in supplements, vitamin E's alpha-tocopherol. `null` where the
+ * level covers the nutrient as such. Clients phrase it in the reader's
+ * language; `source` stays the bare citation.
+ */
+ form: NutrientUpperLimitForm | null;
+ /** The citation (EFSA overview and the opinion it rests on), verbatim. */
source: string;
}
+export type NutrientUpperLimitForm =
+ "retinol" | "folic_acid" | "magnesium_salts" | "alpha_tocopherol";
+
export interface NutrientReference {
/** EFSA value type, named in Coach prose later (Slice B). */
kind: "PRI" | "AI" | "safeLevel";
@@ -222,76 +235,85 @@ const UPPER_LIMITS: Partial> = {
value: 3000,
unit: "ug",
scope: "total",
- source:
- "EFSA UL overview 2024 (SCF 2002): preformed vitamin A (retinol), adults",
+ form: "retinol",
+ source: "EFSA UL overview 2024 (SCF 2002)",
},
vitamin_b6: {
value: 12,
unit: "mg",
scope: "total",
- source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
folate: {
value: 1000,
unit: "ug",
scope: "supplemental",
- source:
- "EFSA UL overview 2024 (NDA Panel 2023): folic acid from supplements and fortified foods, adults",
+ form: "folic_acid",
+ source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
vitamin_d: {
value: 100,
unit: "ug",
scope: "total",
- source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
vitamin_e: {
value: 300,
unit: "mg",
scope: "total",
- source: "EFSA UL overview 2024 (NDA Panel 2024): alpha-tocopherol, adults",
+ form: "alpha_tocopherol",
+ source: "EFSA UL overview 2024 (NDA Panel 2024)",
},
calcium: {
value: 2500,
unit: "mg",
scope: "total",
- source: "EFSA UL overview 2024 (NDA Panel 2012): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (NDA Panel 2012)",
},
magnesium: {
value: 250,
unit: "mg",
scope: "supplemental",
- source:
- "EFSA UL overview 2024 (SCF 2001): readily dissociable magnesium salts in supplements, adults",
+ form: "magnesium_salts",
+ source: "EFSA UL overview 2024 (SCF 2001)",
},
zinc: {
value: 25,
unit: "mg",
scope: "total",
- source: "EFSA UL overview 2024 (SCF 2003): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (SCF 2003)",
},
copper: {
value: 5,
unit: "mg",
scope: "total",
- source: "EFSA UL overview 2024 (SCF 2003): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (SCF 2003)",
},
selenium: {
value: 255,
unit: "ug",
scope: "total",
- source: "EFSA UL overview 2024 (NDA Panel 2023): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (NDA Panel 2023)",
},
molybdenum: {
value: 600,
unit: "ug",
scope: "total",
- source: "EFSA UL overview 2024 (SCF 2000): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (SCF 2000)",
},
iodine: {
value: 600,
unit: "ug",
scope: "total",
- source: "EFSA UL overview 2024 (SCF 2002): adults",
+ form: null,
+ source: "EFSA UL overview 2024 (SCF 2002)",
},
};
diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts
index 2fad5c706..1b3153f55 100644
--- a/src/lib/validations/nutrients.ts
+++ b/src/lib/validations/nutrients.ts
@@ -111,7 +111,17 @@ export const nutrientUpperLimitSchema = z
value: z.number(),
unit: z.string(),
scope: z.enum(["total", "supplemental"]),
- source: z.string(),
+ form: z
+ .enum(["retinol", "folic_acid", "magnesium_salts", "alpha_tocopherol"])
+ .nullable()
+ .describe(
+ "v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A: preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in the reader's language; skip an unknown value.",
+ ),
+ source: z
+ .string()
+ .describe(
+ "The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one.",
+ ),
})
.meta({
id: "NutrientUpperLimit",
From 7167ba8cdcbe49910c0b0f5218b23696987d6cfe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:32:13 +0200
Subject: [PATCH 06/12] fix(labs): pair the panel changes by marker, not by
printed name
---
src/app/api/insights/labs-changes/route.ts | 2 +
.../insights/__tests__/labs-changes.test.ts | 46 +++++++++++++++++++
src/lib/insights/labs-changes.ts | 26 +++++++++--
3 files changed, 69 insertions(+), 5 deletions(-)
diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts
index d48a6b69f..3436b15b6 100644
--- a/src/app/api/insights/labs-changes/route.ts
+++ b/src/app/api/insights/labs-changes/route.ts
@@ -45,6 +45,7 @@ export const GET = apiHandler(async () => {
where: { userId: user.id, deletedAt: null, value: { not: null } },
select: {
analyte: true,
+ biomarkerId: true,
unit: true,
value: true,
referenceLow: true,
@@ -61,6 +62,7 @@ export const GET = apiHandler(async () => {
const summary = summariseLabChanges(
rows.map((r) => ({
analyte: r.analyte,
+ biomarkerId: r.biomarkerId,
unit: r.unit,
value: r.value as number,
referenceLow: r.referenceLow,
diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts
index 71d50807a..f1f003b72 100644
--- a/src/lib/insights/__tests__/labs-changes.test.ts
+++ b/src/lib/insights/__tests__/labs-changes.test.ts
@@ -26,10 +26,12 @@ function row(
sourceLow?: number | null;
sourceHigh?: number | null;
sourceText?: string | null;
+ biomarkerId?: string | null;
} = {},
): LabChangeRow {
return {
analyte,
+ biomarkerId: opts.biomarkerId ?? null,
unit: opts.unit ?? "mg/dL",
value,
referenceLow: opts.low ?? null,
@@ -143,4 +145,48 @@ describe("summariseLabChanges", () => {
);
expect(s.present).toBe(true);
});
+
+ it("pairs two printed names of one marker by the marker", () => {
+ const out = summariseLabChanges(
+ [
+ row("HbA1c", 5.9, "2026-06-01", { unit: "%", biomarkerId: "bm-1" }),
+ row("Glykiertes Hämoglobin", 5.6, "2026-03-01", {
+ unit: "%",
+ biomarkerId: "bm-1",
+ }),
+ ],
+ NOW,
+ );
+ expect(out.present).toBe(true);
+ expect(out.changes).toEqual([
+ expect.objectContaining({
+ analyte: "HbA1c",
+ latest: 5.9,
+ previous: 5.6,
+ delta: 0.3,
+ }),
+ ]);
+ });
+
+ it("keeps two markers apart even when the printed name matches", () => {
+ const out = summariseLabChanges(
+ [
+ row("Glucose", 95, "2026-06-01", { biomarkerId: "fasting" }),
+ row("Glucose", 140, "2026-03-01", { biomarkerId: "postprandial" }),
+ ],
+ NOW,
+ );
+ expect(out.present).toBe(false);
+ });
+
+ it("reports no delta across two units", () => {
+ const out = summariseLabChanges(
+ [
+ row("Glucose", 5.3, "2026-06-01", { unit: "mmol/L", biomarkerId: "g" }),
+ row("Glucose", 95, "2026-03-01", { unit: "mg/dL", biomarkerId: "g" }),
+ ],
+ NOW,
+ );
+ expect(out.present).toBe(false);
+ });
});
diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts
index c3afb2cfe..455e02c6f 100644
--- a/src/lib/insights/labs-changes.ts
+++ b/src/lib/insights/labs-changes.ts
@@ -25,6 +25,12 @@ import {
/** A single numeric lab reading, with both windows it could be judged against. */
export interface LabChangeRow extends SourceReferenceRange {
analyte: string;
+ /**
+ * The marker the reading is filed under. Two reports print the same marker
+ * under different names ("HbA1c", "Glykiertes Hämoglobin"); the marker is
+ * what pairs them. A reading without one pairs by its printed name.
+ */
+ biomarkerId?: string | null;
unit: string;
value: number;
referenceLow: number | null;
@@ -88,9 +94,18 @@ function dayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
-/** Normalised analyte name for cross-panel matching (free-text tolerant). */
-function analyteKey(analyte: string): string {
- return analyte.trim().toLowerCase();
+/**
+ * Cross-panel matching key: the marker when the reading is filed under one,
+ * else the normalised printed name (free-text tolerant).
+ */
+function analyteKey(row: LabChangeRow): string {
+ return row.biomarkerId
+ ? `marker:${row.biomarkerId}`
+ : `name:${row.analyte.trim().toLowerCase()}`;
+}
+
+function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean {
+ return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase();
}
const round2 = (n: number): number => Math.round(n * 100) / 100;
@@ -116,7 +131,7 @@ export function summariseLabChanges(
analytes = new Map();
byDay.set(day, analytes);
}
- const key = analyteKey(row.analyte);
+ const key = analyteKey(row);
const existing = analytes.get(key);
if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) {
analytes.set(key, row);
@@ -144,7 +159,8 @@ export function summariseLabChanges(
const changes: LabChange[] = [];
for (const [key, latestRow] of latest) {
const previousRow = previous.get(key);
- if (!previousRow) continue;
+ // A delta across two units is no change, it is a conversion; skip it.
+ if (!previousRow || !sameUnit(latestRow, previousRow)) continue;
const delta = round2(latestRow.value - previousRow.value);
const direction: LabChange["direction"] =
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
From da1d31e370f583c6bffd74ae0f03e8533ef26baf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:34:08 +0200
Subject: [PATCH 07/12] feat(labs): show and file a reading's related report
and visit in its sheet
---
.../lab-result-related-links.test.tsx | 91 +++++++++++++++++++
src/components/labs/lab-history-list.tsx | 25 ++++-
.../labs/lab-result-related-links.tsx | 67 ++++++++++++++
src/components/labs/types.ts | 4 +
src/lib/query-keys/labs.ts | 5 +
5 files changed, 187 insertions(+), 5 deletions(-)
create mode 100644 src/components/labs/__tests__/lab-result-related-links.test.tsx
create mode 100644 src/components/labs/lab-result-related-links.tsx
diff --git a/src/components/labs/__tests__/lab-result-related-links.test.tsx b/src/components/labs/__tests__/lab-result-related-links.test.tsx
new file mode 100644
index 000000000..b852fa23e
--- /dev/null
+++ b/src/components/labs/__tests__/lab-result-related-links.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { RelatedLink } from "@/lib/links/related";
+import { queryKeys } from "@/lib/query-keys";
+
+const captured = vi.hoisted(() => ({
+ pickers: null as null | {
+ linksPath: string;
+ kinds: readonly string[];
+ onSaved: () => void;
+ },
+}));
+
+vi.mock("@/components/links/related-records", async () => {
+ const actual = await vi.importActual<
+ typeof import("@/components/links/related-records")
+ >("@/components/links/related-records");
+ return {
+ ...actual,
+ RelationLinkPickers: (props: {
+ linksPath: string;
+ kinds: readonly string[];
+ onSaved: () => void;
+ }) => {
+ captured.pickers = props;
+ return null;
+ },
+ };
+});
+
+import { LabResultRelatedLinks } from "../lab-result-related-links";
+
+const REPORT: RelatedLink = {
+ kind: "document",
+ id: "doc-1",
+ label: "Lab report March",
+ date: "2026-03-01T00:00:00.000Z",
+ href: "/documents?doc=doc-1",
+ via: "direct",
+};
+
+function render(qc: QueryClient) {
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+}
+
+/**
+ * A lab reading shows what it relates to where it is shown in detail (its
+ * edit sheet), and files the report and the visit through its own links
+ * route; a save refreshes every end that lists the reading back.
+ */
+describe("LabResultRelatedLinks", () => {
+ it("renders nothing before the detail read lands", () => {
+ expect(render(new QueryClient())).toBe("");
+ });
+
+ it("lists the related records and offers the report and visit pickers", () => {
+ const qc = new QueryClient();
+ qc.setQueryData(queryKeys.labResult("lab-1"), { related: [REPORT] });
+ const html = render(qc);
+ expect(html).toContain('data-slot="lab-result-related"');
+ expect(html).toContain("Lab report March");
+ expect(captured.pickers?.linksPath).toBe("/api/labs/lab-1/links");
+ expect(captured.pickers?.kinds).toEqual(["document", "encounter"]);
+ });
+
+ it("refreshes the reading and every end that lists it after a save", () => {
+ const qc = new QueryClient();
+ qc.setQueryData(queryKeys.labResult("lab-1"), { related: [] });
+ const spy = vi.spyOn(qc, "invalidateQueries");
+ render(qc);
+ captured.pickers?.onSaved();
+ const keys = spy.mock.calls.map((call) => call[0]?.queryKey);
+ expect(keys).toEqual(
+ expect.arrayContaining([
+ queryKeys.labResult("lab-1"),
+ queryKeys.documents(),
+ queryKeys.encounters(),
+ queryKeys.illness(),
+ ]),
+ );
+ });
+});
diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx
index efaf0d32e..2d8f16c2e 100644
--- a/src/components/labs/lab-history-list.tsx
+++ b/src/components/labs/lab-history-list.tsx
@@ -28,7 +28,7 @@ import {
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
-import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
+import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { formatLabReading } from "@/lib/labs/format-value";
import { LabConvertedNote } from "./lab-converted-note";
@@ -40,7 +40,11 @@ import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import { LabReferenceRangeBar } from "./lab-reference-range-bar";
import { SourceRangeNote } from "./source-range-note";
-import type { LabResultDetailDto, LabResultDto } from "./types";
+import {
+ fetchLabResultDetail,
+ LabResultRelatedLinks,
+} from "./lab-result-related-links";
+import type { LabResultDto } from "./types";
import { useLabDate, useLabNumber } from "./use-lab-format";
const NOTE_MAX_LENGTH = 2000;
@@ -242,9 +246,11 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
- const detail = await apiGet(
- `/api/labs/${reading.id}`,
- );
+ // Through the cache: the related section below reads the same detail.
+ const detail = await queryClient.fetchQuery({
+ queryKey: queryKeys.labResult(reading.id),
+ queryFn: () => fetchLabResultDetail(reading.id),
+ });
setEditNote(detail.note ?? "");
setEditSeed({ ...seed, note: detail.note ?? "" });
} catch {
@@ -314,6 +320,9 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
});
}
+ const editingTakenAt =
+ readings.find((reading) => reading.id === editingId)?.takenAt ?? null;
+
const ordered = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
return [...readings].sort((a, b) => {
@@ -593,6 +602,12 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
+ {editingId ? (
+
+ ) : null}
(`/api/labs/${encodeURIComponent(id)}`);
+}
+
+export function LabResultRelatedLinks({
+ labResultId,
+ takenAt,
+}: {
+ labResultId: string;
+ takenAt: string | null;
+}) {
+ const qc = useQueryClient();
+ const detail = useQuery({
+ queryKey: queryKeys.labResult(labResultId),
+ queryFn: () => fetchLabResultDetail(labResultId),
+ });
+ // The pickers seed from this read; before it lands they would offer an
+ // empty selection that the first tap could not compute a change from.
+ if (!detail.data) return null;
+ const related = detail.data.related;
+ return (
+
+
+ {
+ // The other ends list the reading too: the document's and the
+ // visit's sheets seed their pickers from their cached reads, and a
+ // condition reaches the reading through the visit.
+ for (const queryKey of [
+ queryKeys.labResult(labResultId),
+ queryKeys.documents(),
+ queryKeys.encounters(),
+ queryKeys.illness(),
+ ]) {
+ void qc.invalidateQueries({ queryKey });
+ }
+ }}
+ />
+
+ );
+}
diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts
index a523a6585..d7a0e2816 100644
--- a/src/components/labs/types.ts
+++ b/src/components/labs/types.ts
@@ -1,3 +1,5 @@
+import type { RelatedLink } from "@/lib/links/related";
+
import type {
ReferenceRangeOrigin,
ReferenceRangeStatus,
@@ -58,6 +60,8 @@ export interface LabResultDto {
/** The single-resource detail DTO — carries the decrypted note. */
export interface LabResultDetailDto extends Omit {
note: string | null;
+ /** v1.43 — the records this reading relates to, resolved on the server. */
+ related?: RelatedLink[];
}
export interface LabResultListResponse {
diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts
index ee585e878..aa1a6291b 100644
--- a/src/lib/query-keys/labs.ts
+++ b/src/lib/query-keys/labs.ts
@@ -34,6 +34,11 @@ export const labKeys = {
// mutation's `labResults()` invalidation evict it.
labResultsOverview: () => ["lab-results", "overview"] as const,
+ // One reading's detail (`GET /api/labs/{id}`): the decrypted note and the
+ // records it relates to. Under the `lab-results` prefix so every result
+ // mutation evicts it.
+ labResult: (id: string) => ["lab-results", "detail", id] as const,
+
// `/labs?analyte=` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`
From 6776decaa03757d1fd3b7c4aa8671b2cd21542b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:35:48 +0200
Subject: [PATCH 08/12] test(guard): discover the resolved fields and demand a
reader or a stated reason for each
---
.../resolved-field-consumer-guard.test.ts | 221 +++++++++++++++---
1 file changed, 191 insertions(+), 30 deletions(-)
diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts
index 7caab3bd6..397a6af7f 100644
--- a/src/__tests__/resolved-field-consumer-guard.test.ts
+++ b/src/__tests__/resolved-field-consumer-guard.test.ts
@@ -292,11 +292,19 @@ describe("resolved-field consumer guard", () => {
/**
* v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves
- * for the native client reach the web as well, so the two clients render the
- * same answer. Each field names the web module that reads it, by a pattern
- * over that module's code. A field the web has no screen for names the web
- * twin it is resolved alongside (the same resolver, published where the web
- * reads it) and why; the twin's reader is checked the same way.
+ * for the native client either reach the web, or say why they do not.
+ *
+ * The inventory is not hand-kept: every response property in the OpenAPI
+ * route modules typed by one of the resolved-verdict schemas
+ * (`src/lib/openapi/routes/trend-verdict.ts`) is discovered, and each must be
+ * claimed by exactly one entry below. An entry either names the web module
+ * that reads the field, by a pattern that names the field itself, or names
+ * the field iOS-only with the reason, and where the reason is "the web reads
+ * the same answer elsewhere", the server modules that must share the
+ * resolver for that to stay true. A new resolved field with neither fails.
+ *
+ * Entries without a discovered property (`VITALS_BASELINE` sits in an open
+ * record, `lastBackfillAt` is a plain timestamp) carry a web reader by hand.
*
* The rebuild side of the same rule (a client deciding a polarity or drawing
* a threshold of its own) is `client-threshold-literal-guard.test.ts`.
@@ -306,16 +314,39 @@ interface WebReader {
pattern: RegExp;
}
-interface ResolvedFieldReader {
+interface ResolvedFieldEntry {
field: string;
- readers: WebReader[];
- /** Set when the web reads a twin rather than this route's field. */
- twin?: string;
+ /** Discovered properties this entry claims: `#`. */
+ props: string[];
+ /** The web modules that read the field. */
+ readers?: WebReader[];
+ /** Why the web has no reader; required when `readers` is absent. */
+ iosOnly?: {
+ reason: string;
+ /**
+ * When the reason is that the web reads the same answer from another
+ * route: every file here must call the resolver both answers come from.
+ */
+ sameResolver?: { pattern: RegExp; files: string[] };
+ };
}
-const V143_FIELDS: ResolvedFieldReader[] = [
+const ROUTES_DIR = "src/lib/openapi/routes";
+const RESOLVED_SCHEMAS = [
+ "trendVerdict",
+ "trendSentiment",
+ "seriesChange",
+ "seriesReferenceLine",
+ "recordBenchmark",
+ "moodPattern",
+ "cycleRestoredStart",
+];
+const VERDICT_RESOLVER = /\bresolve(?:TrendVerdict|SummaryVerdicts)\(/;
+
+const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/dashboard/snapshot tiles.directions",
+ props: ["insights/schemas.ts#directions"],
readers: [
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
],
@@ -323,18 +354,40 @@ const V143_FIELDS: ResolvedFieldReader[] = [
{
field:
"GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment",
- twin: "the summary route is the native client's dashboard; the web dashboard reads the same `resolveTrendVerdict` answer from the snapshot's `tiles.directions`",
- readers: [{ file: "src/app/page-client.tsx", pattern: /directionOf\(/ }],
+ props: ["dashboard.ts#direction", "dashboard.ts#trendSentiment"],
+ iosOnly: {
+ reason:
+ "the summary route is the native client's dashboard; the web dashboard colours its tiles from the snapshot's `tiles.directions`, resolved by the same verdict resolver",
+ sameResolver: {
+ pattern: VERDICT_RESOLVER,
+ files: [
+ "src/app/api/dashboard/summary/route.ts",
+ "src/lib/dashboard/snapshot.ts",
+ ],
+ },
+ },
},
{
field: "GET /api/insights/targets TargetItem.direction / trendSentiment",
- twin: "the web target panels show the band, not a change; the change they would colour is the dashboard tile's, read from `tiles.directions`",
- readers: [
- { file: "src/app/page-client.tsx", pattern: /tileDirections\?\.\[key\]/ },
+ props: [
+ "insights/schemas.ts#direction",
+ "insights/schemas.ts#trendSentiment",
],
+ iosOnly: {
+ reason:
+ "the web target panels show the band and where the value sits, never a change, so there is nothing to colour; the native targets page colours the change, placed on the dashboard's seven-day basis",
+ sameResolver: {
+ pattern: VERDICT_RESOLVER,
+ files: [
+ "src/lib/targets/build-response.ts",
+ "src/lib/dashboard/snapshot.ts",
+ ],
+ },
+ },
},
{
field: "GET /api/insights/derived VITALS_BASELINE value.direction",
+ props: [],
readers: [
{
file: "src/components/insights/derived/vitals-dashboard.tsx",
@@ -344,11 +397,27 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/measurements/series direction / change / referenceLines",
- twin: "the web charts read the series-batch and draw the person's bands from the snapshot's `targetBands`; the series read is the native chart source",
- readers: [{ file: "src/app/page-client.tsx", pattern: /targetBands/ }],
+ props: [
+ "measurements.ts#direction",
+ "measurements.ts#change",
+ "measurements.ts#referenceLines",
+ "trend-verdict.ts#sentiment",
+ ],
+ iosOnly: {
+ reason:
+ "the web charts are fed by `/api/measurements/series-batch`, which carries no verdict, change or reference line; the web colours a chart's change from the tile verdict in `tiles.directions` and draws the person's bands from the snapshot's `targetBands`. The single-series read is the native chart source.",
+ sameResolver: {
+ pattern: VERDICT_RESOLVER,
+ files: [
+ "src/app/api/measurements/series/route.ts",
+ "src/lib/dashboard/snapshot.ts",
+ ],
+ },
+ },
},
{
field: "GET /api/personal-records benchmark",
+ props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
@@ -358,6 +427,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/mood/insights patterns",
+ props: ["mood.ts#patterns"],
readers: [
{
file: "src/components/insights/mood/mood-insights-breakdowns.tsx",
@@ -371,6 +441,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "POST /api/cycle/period absorbed",
+ props: ["cycle.ts#absorbed"],
readers: [
{
file: "src/components/cycle/use-cycle.ts",
@@ -380,6 +451,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored",
+ props: ["cycle.ts#restored", "trend-verdict.ts#restored"],
readers: [
{
file: "src/components/cycle/use-cycle.ts",
@@ -393,6 +465,7 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
{
field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt",
+ props: [],
readers: [
{
file: "src/components/settings/integrations/apple-health-card.tsx",
@@ -402,20 +475,108 @@ const V143_FIELDS: ResolvedFieldReader[] = [
},
];
-describe("v1.43 resolved fields reach the web", () => {
+/**
+ * Every response property typed by a resolved-verdict schema, as
+ * `#`: `name: schema`, `name: z.array(schema)` and
+ * `name: z.record(z.string(), schema)`, across line breaks. The schema
+ * definitions themselves count too (`seriesChange.sentiment`,
+ * `cycleDeleteOutcome.restored`), since they reach the wire nested.
+ */
+function discoverResolvedProps(
+ sources: ReadonlyArray<{ rel: string; code: string }>,
+): string[] {
+ const names = RESOLVED_SCHEMAS.join("|");
+ const prop = new RegExp(
+ String.raw`(\w+)\s*:\s*(?:z\s*\.\s*(?:array|record)\(\s*(?:z\s*\.\s*string\(\)\s*,\s*)?)?(?:${names})\b`,
+ "g",
+ );
+ const out: string[] = [];
+ for (const { rel, code } of sources) {
+ for (const match of code.matchAll(prop)) out.push(`${rel}#${match[1]}`);
+ }
+ return out;
+}
+
+function routeSources(): Array<{ rel: string; code: string }> {
+ return walkSourceFiles(join(ROOT, ROUTES_DIR), { floor: 20 })
+ .filter((file) => !/__tests__|\.test\.ts$/.test(file))
+ .map((file) => ({
+ rel: file,
+ code: stripComments(readFileSync(join(ROOT, ROUTES_DIR, file), "utf8")),
+ }));
+}
+
+function unescaped(pattern: RegExp): string {
+ return pattern.source.replace(/\\/g, "");
+}
+
+describe("v1.43 resolved fields reach the web, or say why not", () => {
+ const discovered = discoverResolvedProps(routeSources());
+
+ it("discovers the resolved fields from the route modules", () => {
+ // A matcher that stopped matching would let every field go unclaimed
+ // without anyone noticing; the inventory has twelve today.
+ expect(discovered.length).toBeGreaterThanOrEqual(12);
+ expect(
+ discoverResolvedProps([
+ {
+ rel: "x.ts",
+ code: "a: trendVerdict.nullable(), b: z\n .array(moodPattern), c: z.record(z.string(), trendVerdict)",
+ },
+ ]),
+ ).toEqual(["x.ts#a", "x.ts#b", "x.ts#c"]);
+ });
+
+ it("claims every discovered field exactly once, and nothing else", () => {
+ const claimed = V143_FIELDS.flatMap((entry) => entry.props);
+ const unclaimed = discovered.filter((key) => !claimed.includes(key));
+ expect(
+ unclaimed,
+ "a resolved field has no web reader and no iOS-only reason: add an entry",
+ ).toEqual([]);
+ const stale = claimed.filter((key) => !discovered.includes(key));
+ expect(stale, "an entry claims a field the routes no longer serve").toEqual(
+ [],
+ );
+ expect(new Set(claimed).size).toBe(claimed.length);
+ });
+
for (const entry of V143_FIELDS) {
- it(`${entry.field} has a web reader${entry.twin ? " through its twin" : ""}`, () => {
- expect(entry.readers.length).toBeGreaterThan(0);
- if (entry.twin !== undefined)
- expect(entry.twin.length).toBeGreaterThan(40);
- for (const reader of entry.readers) {
- const code = stripComments(
- readFileSync(join(ROOT, reader.file), "utf8"),
- );
- expect(
- reader.pattern.test(code),
- `${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
- ).toBe(true);
+ it(`${entry.field} ${entry.readers ? "has a web reader" : "is iOS-only for a stated reason"}`, () => {
+ expect(
+ (entry.readers ? 1 : 0) + (entry.iosOnly ? 1 : 0),
+ "an entry has a web reader or an iOS-only reason, not both or neither",
+ ).toBe(1);
+ if (entry.readers) {
+ expect(entry.readers.length).toBeGreaterThan(0);
+ // The reader must read the field itself, not something beside it.
+ for (const key of entry.props) {
+ const name = key.split("#")[1]!;
+ expect(
+ entry.readers.some((reader) =>
+ unescaped(reader.pattern).includes(name),
+ ),
+ `no reader pattern names \`${name}\``,
+ ).toBe(true);
+ }
+ for (const reader of entry.readers) {
+ const code = stripComments(
+ readFileSync(join(ROOT, reader.file), "utf8"),
+ );
+ expect(
+ reader.pattern.test(code),
+ `${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
+ ).toBe(true);
+ }
+ } else {
+ expect(entry.iosOnly!.reason.length).toBeGreaterThan(60);
+ for (const file of entry.iosOnly!.sameResolver?.files ?? []) {
+ const code = stripComments(readFileSync(join(ROOT, file), "utf8"));
+ expect(
+ entry.iosOnly!.sameResolver!.pattern.test(code),
+ `${file} no longer resolves ${entry.field} with the shared resolver`,
+ ).toBe(true);
+ }
}
});
}
From 2fc74e76dfbf9163285416ac45e5139d96713635 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:35:52 +0200
Subject: [PATCH 09/12] fix(targets): place the target verdict on the
dashboard's seven-day basis
---
.../__tests__/target-verdict-basis.test.ts | 90 ++++++++++++++++++
src/lib/targets/build-response.ts | 93 +++++++++++++++----
2 files changed, 163 insertions(+), 20 deletions(-)
create mode 100644 src/lib/targets/__tests__/target-verdict-basis.test.ts
diff --git a/src/lib/targets/__tests__/target-verdict-basis.test.ts b/src/lib/targets/__tests__/target-verdict-basis.test.ts
new file mode 100644
index 000000000..bd423a5bd
--- /dev/null
+++ b/src/lib/targets/__tests__/target-verdict-basis.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({ prisma: {} }));
+
+import { targetVerdictReference, withTargetVerdicts } from "../build-response";
+import { resolveSummaryVerdicts } from "../trend-verdict";
+import type { TargetItem } from "../types";
+
+const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
+const CTX = {
+ profile: { dateOfBirth: null, gender: null, heightCm: null },
+ thresholdsJson: OWN_BP,
+};
+
+function item(
+ type: string,
+ current: number | null,
+ average30: number | null = null,
+): TargetItem {
+ return {
+ type,
+ label: type,
+ current,
+ average30,
+ trend: "up",
+ unit: "",
+ range: null,
+ classification: null,
+ source: "",
+ } as TargetItem;
+}
+
+/**
+ * The target tile and the dashboard tile judge the same change, so they are
+ * placed with the same value: the seven-day mean, else the latest reading,
+ * from the dashboard's summary slice.
+ */
+describe("target verdict basis", () => {
+ it("places a target with the dashboard's seven-day mean, not its last reading", () => {
+ // Last reading 118 (inside the own band), seven-day mean 132 (above it).
+ const summaries = { BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 } };
+ const [bp] = withTargetVerdicts(
+ [item("BLOOD_PRESSURE", 118, 121)],
+ CTX,
+ summaries,
+ );
+ const dashboard = resolveSummaryVerdicts(summaries, CTX);
+ expect(bp!.direction).toEqual(dashboard.BLOOD_PRESSURE_SYS);
+ expect(bp!.direction?.targetPosition).toBe("above");
+ });
+
+ it("follows the resting heart rate for the pulse target when it exists", () => {
+ expect(
+ targetVerdictReference(
+ item("PULSE", 70),
+ { RESTING_HEART_RATE: { avg7: 52, latest: 50 } },
+ undefined,
+ ),
+ ).toBe(52);
+ expect(
+ targetVerdictReference(
+ item("PULSE", 70),
+ { PULSE: { avg7: null, latest: 64 } },
+ undefined,
+ ),
+ ).toBe(64);
+ });
+
+ it("judges BMI by the weight summary", () => {
+ expect(
+ targetVerdictReference(
+ item("BMI", 24),
+ { WEIGHT: { avg7: 80, latest: 81 } },
+ undefined,
+ ),
+ ).toBe(80);
+ });
+
+ it("falls back to the item's own value without a summary", () => {
+ expect(
+ targetVerdictReference(item("SLEEP_DURATION", 7), {}, undefined),
+ ).toBe(420);
+ expect(
+ targetVerdictReference(item("BMI", 24), {}, item("WEIGHT", null, 79)),
+ ).toBe(79);
+ expect(
+ targetVerdictReference(item("MOOD_SCORE", null), {}, undefined),
+ ).toBe(null);
+ });
+});
diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts
index 4ce2e7d19..73a0e162f 100644
--- a/src/lib/targets/build-response.ts
+++ b/src/lib/targets/build-response.ts
@@ -31,6 +31,7 @@ import {
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
+import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
import { buildGlucoseTargets } from "./glucose-builder";
import { readGlucoseTargetSummaries } from "./glucose-read";
import { buildMedicationTarget } from "./medication-builder";
@@ -112,6 +113,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
latestMoodEntry,
glucoseSummaries,
restingPulseProxy,
+ dashboardSummaries,
] = await Promise.all([
limit(() =>
prisma.user.findUnique({
@@ -247,6 +249,11 @@ export async function buildTargetsResponse(user: AuthedUser) {
timeZone: timezone,
}),
),
+ // The verdict is placed with the value the dashboard tile places it
+ // with (the seven-day mean, else the latest reading), read from the same
+ // summary slice, so a target and its tile never disagree on which way a
+ // change counts.
+ limit(() => computeSummariesSlice(userId)),
]);
// Rollup coverage fallback stays an explicit repository read here rather
@@ -369,14 +376,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
// v1.43 (ios #116 item 3) — every item carries which way its change
// counts, judged with the same rule the dashboard tiles use.
- const verdictTargets = withTargetVerdicts(targets, {
- profile: {
- heightCm,
- dateOfBirth: dbUser?.dateOfBirth ?? null,
- gender: dbUser?.gender ?? null,
+ const verdictTargets = withTargetVerdicts(
+ targets,
+ {
+ profile: {
+ heightCm,
+ dateOfBirth: dbUser?.dateOfBirth ?? null,
+ gender: dbUser?.gender ?? null,
+ },
+ thresholdsJson: dbUser?.thresholdsJson ?? null,
},
- thresholdsJson: dbUser?.thresholdsJson ?? null,
- });
+ dashboardSummaries.summaries,
+ );
targets.splice(0, targets.length, ...verdictTargets);
const pageSummary = buildTargetPageSummary(targets);
@@ -412,28 +423,70 @@ export async function buildTargetsResponse(user: AuthedUser) {
}
/**
- * Lay the verdict over each target item. The reference is the item's latest
- * value, else its thirty-day average, in the unit the item is served in:
- * sleep is served in hours and judged in minutes, and BMI is judged by the
- * weight it is derived from.
+ * The dashboard summary each target item's verdict is placed with: the key
+ * its dashboard tile reads its direction under. Blood pressure is judged by
+ * its systolic side, BMI by the weight it is derived from, and the resting
+ * pulse by the resting heart rate where the account records one (the tile
+ * shows that series then).
+ */
+function summaryKeysFor(type: string): string[] {
+ switch (type) {
+ case "BLOOD_PRESSURE":
+ return ["BLOOD_PRESSURE_SYS"];
+ case "BMI":
+ return ["WEIGHT"];
+ case "PULSE":
+ return ["RESTING_HEART_RATE", "PULSE"];
+ default:
+ return [type];
+ }
+}
+
+type VerdictSummaries = Readonly<
+ Record
+>;
+
+/**
+ * The value a target's verdict is placed with, in the stored unit: the same
+ * basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean
+ * else the latest reading. Only when the account has no summary for the type
+ * does the item's own value stand in (latest, else thirty-day average; sleep
+ * is served in hours and stored in minutes).
+ */
+export function targetVerdictReference(
+ item: Pick,
+ summaries: VerdictSummaries,
+ weightItem: Pick | undefined,
+): number | null {
+ for (const key of summaryKeysFor(item.type)) {
+ const summary = summaries[key];
+ const value = summary ? (summary.avg7 ?? summary.latest) : null;
+ if (value !== null && value !== undefined) return value;
+ }
+ const own =
+ item.type === "BMI"
+ ? weightItem
+ ? (weightItem.current ?? weightItem.average30)
+ : null
+ : (item.current ?? item.average30);
+ if (own === null) return null;
+ return item.type === "SLEEP_DURATION" ? own * 60 : own;
+}
+
+/**
+ * Lay the verdict over each target item, placed with the dashboard's basis
+ * (`targetVerdictReference`).
*/
export function withTargetVerdicts(
targets: readonly TargetItem[],
ctx: Omit,
+ summaries: VerdictSummaries,
): TargetItem[] {
const weight = targets.find((item) => item.type === "WEIGHT");
- const weightReference = weight ? (weight.current ?? weight.average30) : null;
return targets.map((item) => {
- const own = item.current ?? item.average30;
- const reference =
- item.type === "BMI"
- ? weightReference
- : own !== null && item.type === "SLEEP_DURATION"
- ? own * 60
- : own;
const direction = resolveTrendVerdict(item.type, {
...ctx,
- referenceValue: reference,
+ referenceValue: targetVerdictReference(item, summaries, weight),
});
return {
...item,
From a8b46b9ac0e19f85999cbf7a1f1974f60cdf2465 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:37:18 +0200
Subject: [PATCH 10/12] docs(api): regenerate the OpenAPI contract
---
docs/api/openapi.yaml | 22 ++++++++++++++++++++--
1 file changed, 20 insertions(+), 2 deletions(-)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index f735b0d4e..3440fa985 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -46822,8 +46822,11 @@ components:
- up-bad
- neutral
- hold
- description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside
- its band so staying is progress, `neutral` no judgement."
+ description: "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress
+ and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood
+ pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below
+ the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is
+ never worse)."
targetPosition:
anyOf:
- type: string
@@ -75952,12 +75955,27 @@ components:
enum:
- total
- supplemental
+ form:
+ anyOf:
+ - type: string
+ enum:
+ - retinol
+ - folic_acid
+ - magnesium_salts
+ - alpha_tocopherol
+ - type: "null"
+ description: "v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A:
+ preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable
+ salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in
+ the reader's language; skip an unknown value."
source:
type: string
+ description: The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one.
required:
- value
- unit
- scope
+ - form
- source
additionalProperties: false
description: The tolerable upper intake level, with the unit, whether it applies to the total intake or to the
From 13c74c4e9226f091366a92861e9b5d6fd22fc1ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:44:17 +0200
Subject: [PATCH 11/12] test(targets): pin the resting pulse verdict below a
default band as hold
---
src/app/api/insights/targets/__tests__/route.test.ts | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts
index ed3041d2a..d9fe9db14 100644
--- a/src/app/api/insights/targets/__tests__/route.test.ts
+++ b/src/app/api/insights/targets/__tests__/route.test.ts
@@ -39,6 +39,11 @@ vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
readRestingPulseProxy: vi.fn(),
}));
+// The verdict's basis: the dashboard's summary slice. Empty here, so every
+// item is placed with its own value (`targetVerdictReference`).
+vi.mock("@/lib/analytics/summaries-slice", () => ({
+ computeSummariesSlice: vi.fn(async () => ({ summaries: {} })),
+}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -596,12 +601,15 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
color: "var(--warning)",
},
source: "CDC/NCHS",
+ // Below a default band nobody chose, a resting pulse holds: its
+ // lower edge is a convention, not a goal to climb back to, so a
+ // steady reading is the good news and no move is a setback.
direction: {
- direction: "up-good",
+ direction: "hold",
targetPosition: "below",
basis: "profile_default",
},
- trendSentiment: "neutral",
+ trendSentiment: "positive",
daysInRange7d: 0,
daysLogged7d: 4,
daysInRange30d: 0,
From abbb424d2187becd748406d3adce56a7ee66daac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 16 Oct 2026 05:49:51 +0200
Subject: [PATCH 12/12] fix(targets): read the seven-day basis as one aggregate
instead of the whole summary slice
---
.../insights/targets/__tests__/route.test.ts | 6 +--
src/lib/analytics/summaries-slice.ts | 41 +++++++++++++++
src/lib/targets/build-response.ts | 51 ++++++++++++++++---
tests/integration/dense-targets-reads.test.ts | 23 +++++++++
4 files changed, 111 insertions(+), 10 deletions(-)
diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts
index d9fe9db14..9338f928d 100644
--- a/src/app/api/insights/targets/__tests__/route.test.ts
+++ b/src/app/api/insights/targets/__tests__/route.test.ts
@@ -39,10 +39,10 @@ vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({
vi.mock("@/lib/analytics/resting-pulse-read", () => ({
readRestingPulseProxy: vi.fn(),
}));
-// The verdict's basis: the dashboard's summary slice. Empty here, so every
-// item is placed with its own value (`targetVerdictReference`).
+// The verdict's basis: the dashboard's seven-day means. Empty here, so every
+// item is placed with its newest reading, else its own value.
vi.mock("@/lib/analytics/summaries-slice", () => ({
- computeSummariesSlice: vi.fn(async () => ({ summaries: {} })),
+ readSevenDayMeans: vi.fn(async () => ({})),
}));
vi.mock("@/lib/db", () => ({
diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts
index 9766410a9..b1a5519a2 100644
--- a/src/lib/analytics/summaries-slice.ts
+++ b/src/lib/analytics/summaries-slice.ts
@@ -500,6 +500,47 @@ async function withSleepNightTotals(
return slice;
}
+/**
+ * The seven-day mean per type, exactly as the slice computes `avg7` (the same
+ * canonical-source CTE, the same day weighting, the same rounding), without
+ * the rest of the slice. Sleep is absent: the slice summarises it per night. For a reader that needs only the dashboard's basis,
+ * such as the target verdicts: one aggregate query, no reading ever leaves
+ * the database as a row.
+ */
+export async function readSevenDayMeans(
+ userId: string,
+): Promise> {
+ const priorityJson = await loadUserSourcePriority(userId);
+ const rankUnqualified = buildSourceRankCase(
+ priorityJson,
+ '"type"',
+ '"source"',
+ );
+ const rows = await prisma.$queryRawUnsafe<
+ Array<{ type: string; avg7: number | null }>
+ >(
+ `
+ WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")}
+ )
+ SELECT
+ m."type"::text AS type,
+ ${AVG7_SQL}::double precision AS avg7
+ FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m
+ GROUP BY m."type"
+ `,
+ userId,
+ );
+ const out: Record = {};
+ for (const row of rows) {
+ // Stored per stage, summarised per night by `withSleepNightTotals`: a
+ // mean over stage rows is not the slice's sleep figure, so it is left
+ // out rather than served wrong. Callers fold the nights themselves.
+ if (row.type === "SLEEP_DURATION") continue;
+ out[row.type] = round2(row.avg7);
+ }
+ return out;
+}
+
/**
* Happy path — DAY buckets carry `count / min / max / mean`; a narrow
* `$queryRaw` carries only the windowed avgs + regression columns the
diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts
index 73a0e162f..7b0ddc569 100644
--- a/src/lib/targets/build-response.ts
+++ b/src/lib/targets/build-response.ts
@@ -31,7 +31,11 @@ import {
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
-import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
+import { readSevenDayMeans } from "@/lib/analytics/summaries-slice";
+import {
+ summarizeSleepNights,
+ type SleepStageRow,
+} from "@/lib/analytics/sleep-night";
import { buildGlucoseTargets } from "./glucose-builder";
import { readGlucoseTargetSummaries } from "./glucose-read";
import { buildMedicationTarget } from "./medication-builder";
@@ -113,7 +117,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
latestMoodEntry,
glucoseSummaries,
restingPulseProxy,
- dashboardSummaries,
+ sevenDayMeans,
] = await Promise.all([
limit(() =>
prisma.user.findUnique({
@@ -250,10 +254,9 @@ export async function buildTargetsResponse(user: AuthedUser) {
}),
),
// The verdict is placed with the value the dashboard tile places it
- // with (the seven-day mean, else the latest reading), read from the same
- // summary slice, so a target and its tile never disagree on which way a
- // change counts.
- limit(() => computeSummariesSlice(userId)),
+ // with: the seven-day mean, computed by the summary slice's own query,
+ // so a target and its tile never disagree on which way a change counts.
+ limit(() => readSevenDayMeans(userId)),
]);
// Rollup coverage fallback stays an explicit repository read here rather
@@ -386,7 +389,15 @@ export async function buildTargetsResponse(user: AuthedUser) {
},
thresholdsJson: dbUser?.thresholdsJson ?? null,
},
- dashboardSummaries.summaries,
+ verdictSummaries(sevenDayMeans, latestByType, {
+ // Sleep the way the dashboard summarises it: per night, from the
+ // same stage rows and source ladder.
+ SLEEP_DURATION: summarizeSleepNights(
+ sleepStageRows as SleepStageRow[],
+ timezone,
+ dbUser?.sourcePriorityJson ?? null,
+ ).summary,
+ }),
);
targets.splice(0, targets.length, ...verdictTargets);
@@ -446,6 +457,32 @@ type VerdictSummaries = Readonly<
Record
>;
+/**
+ * The dashboard's `{ avg7, latest }` per type: the slice's seven-day mean,
+ * and the newest reading where there is none in the last seven days.
+ */
+function verdictSummaries(
+ sevenDayMeans: Readonly>,
+ latestByType: TargetValueByType,
+ folded: Readonly<
+ Record
+ >,
+): VerdictSummaries {
+ const out: Record =
+ {};
+ const types = new Set([
+ ...Object.keys(sevenDayMeans),
+ ...Object.keys(latestByType),
+ ]);
+ for (const type of types) {
+ out[type] = {
+ avg7: sevenDayMeans[type] ?? null,
+ latest: latestByType[type as keyof TargetValueByType] ?? null,
+ };
+ }
+ return { ...out, ...folded };
+}
+
/**
* The value a target's verdict is placed with, in the stored unit: the same
* basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean
diff --git a/tests/integration/dense-targets-reads.test.ts b/tests/integration/dense-targets-reads.test.ts
index 5cad59b59..f2bf0c2ce 100644
--- a/tests/integration/dense-targets-reads.test.ts
+++ b/tests/integration/dense-targets-reads.test.ts
@@ -13,6 +13,10 @@ import { beforeAll, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse";
+import {
+ computeSummariesSlice,
+ readSevenDayMeans,
+} from "@/lib/analytics/summaries-slice";
import { buildTargetsResponse } from "@/lib/targets/build-response";
import { buildGlucoseTargets } from "@/lib/targets/glucose-builder";
import {
@@ -294,3 +298,22 @@ describe("targets fold equals the raw-reading fold", () => {
});
}
});
+
+// The target verdict is placed with the dashboard tile's basis. Proven on
+// the database, not assumed: the seven-day means the targets read equal the
+// `avg7` the dashboard slice serves, type for type, on both accounts.
+describe("target verdict basis equals the dashboard's", () => {
+ for (const userId of [SPARSE, DENSE]) {
+ it(`reads the slice's seven-day mean (${userId})`, async () => {
+ const [means, slice] = await Promise.all([
+ readSevenDayMeans(userId),
+ computeSummariesSlice(userId),
+ ]);
+ const withData = Object.keys(means);
+ expect(withData.length).toBeGreaterThan(0);
+ for (const type of withData) {
+ expect(means[type], type).toBe(slice.summaries[type]?.avg7 ?? null);
+ }
+ });
+ }
+});