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); + } + }); + } +});