From 8168e60dfd63b871884ce1d3b41892b453e92f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:19:17 +0200 Subject: [PATCH 1/4] fix(dashboard): stop the widgets PUT discarding a comparison baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout PUT preserved chartOverlayPrefs, selectedScoreRings and heroRingOrder when a client omitted them, but the top-level comparisonBaseline was not in that merge. serializeDashboardLayout clamps a missing baseline to "none", so a layout save from a client that does not know the field — the native client treats it as web-only and never sends it — silently reset the comparison the user had picked on the web. No error, no audit row. Replace the hand-maintained three-field merge with an explicit disposition map over every top-level layout field. The map is declared `satisfies Record`, so adding a layout field without deciding replace-or-preserve fails typecheck; the preserve set and the merge are derived from it rather than restated. The stored-layout read is skipped when the client sent the full set. Also catalogue the four clinical signals the native client pins — grip strength, pain NRS, waist circumference, waist-to-height. They reached the route as unknown ids, were filtered out ahead of Zod, and vanished from the persisted layout, so a tile placement was lost on every save. They join the iOS-only set because the web dashboard has no render path for them; their charts live on the insights sub-pages. Raise the widget array cap to 50 to clear the now-42-id catalogue. --- .../dashboard/widgets/__tests__/route.test.ts | 119 +++++++++++ src/app/api/dashboard/widgets/route.ts | 68 +++---- src/lib/__tests__/dashboard-layout.test.ts | 185 +++++++++++++++++- src/lib/dashboard-layout.ts | 101 ++++++++++ 4 files changed, 432 insertions(+), 41 deletions(-) diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 53e9bc085..5611f3fc3 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => { ); }); }); + +describe("dashboard widgets — preserve-when-absent on PUT", () => { + it("keeps the stored comparisonBaseline when the client omits it", async () => { + // The regression: a layout save from a client that doesn't know + // `comparisonBaseline` (the native client documents the field as + // web-only and never sends it) used to fall through to the serializer, + // which clamps a missing baseline to "none". The user's web-chosen + // comparison silently reset on every tile reorder from the phone. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [ + { id: "weight", visible: false, tileVisible: true, order: 0 }, + ], + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { comparisonBaseline: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe( + "lastYear", + ); + + const body = (await res.json()) as { + data: { comparisonBaseline: string }; + }; + expect(body.data.comparisonBaseline).toBe("lastYear"); + }); + + it("honours an explicitly sent comparisonBaseline over the stored one", async () => { + // Preserve-when-absent must not become preserve-always: the web + // CompareToggle sends the field, including an explicit "none" to clear. + const stored: DashboardLayout = serializeDashboardLayout({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "none", + }), + ); + expect(res.status).toBe(200); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { comparisonBaseline: string } }; + }; + expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none"); + }); + + it("persists the four clinical tiles the native client pins", async () => { + // They used to hit the unknown-id filter ahead of Zod and vanish from + // the persisted layout, so the placement was lost on every save. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const clinicalIds = [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]; + const res = await callPut( + makeReq({ + version: 1, + widgets: clinicalIds.map((id, i) => ({ + id, + visible: true, + tileVisible: true, + order: i, + })), + }), + ); + expect(res.status).toBe(200); + + const dropAnnotate = vi + .mocked(annotate) + .mock.calls.find( + (c) => + (c[0] as { action?: { name?: string } }).action?.name === + "dashboard.widgets.unknown-id-dropped", + ); + expect(dropAnnotate).toBeUndefined(); + + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } }; + }; + const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map( + (w) => w.id, + ); + for (const id of clinicalIds) { + expect(persistedIds).toContain(id); + } + }); +}); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 3522e63b6..71b4df844 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -26,10 +26,9 @@ import { MAX_SELECTED_SCORE_RINGS, HERO_RING_IDS, MAX_HERO_RING_ORDER, - type ChartOverlayPrefsMap, + layoutNeedsPreserveRead, + mergePreservedLayoutFields, type DashboardLayout, - type ScoreRingId, - type HeroRingId, } from "@/lib/dashboard-layout"; import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; @@ -80,7 +79,12 @@ const layoutSchema = z.object({ // 11 iOS-only = 35) fits with headroom after the v1.10 additive // HealthKit signals became pinnable; the enum still bounds each id to // one of the catalogue ids. - .max(40), + // + // Raised again to 50 when the four clinical signals joined the + // catalogue (42 ids). The cap only has to clear the catalogue size — + // the enum bounds each entry and the id set is closed — so it carries + // headroom rather than tracking the count exactly. + .max(50), // v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides // on the layout blob per research §7 Q3 (no Prisma migration). Optional // so v1.4.15 clients that don't know the field can still PUT. @@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - // v1.4.18 — preserve any per-chart overlay prefs that the client - // didn't send. The dashboard-layout PUT typically saves widget - // visibility / order; chart prefs are PUT through their own route - // (`/api/dashboard/chart-overlay-prefs`) and would otherwise be - // wiped here on a subsequent layout save. `selectedScoreRings` and - // `heroRingOrder` ride the same preserve-when-absent contract — an - // older client's layout save must not reset either choice. One - // stored-layout read covers all fallbacks. - let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data - .chartOverlayPrefs as ChartOverlayPrefsMap | undefined; - let mergedScoreRings: ScoreRingId[] | undefined = - parsed.data.selectedScoreRings; - let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder; - if ( - mergedChartOverlayPrefs === undefined || - mergedScoreRings === undefined || - mergedHeroRingOrder === undefined - ) { + // Preserve-when-absent: every field the client didn't send is carried + // forward from the stored layout rather than clamped to its default by + // the serializer. The dashboard-layout PUT typically saves widget + // visibility / order, while chart prefs are PUT through their own route + // (`/api/dashboard/chart-overlay-prefs`), the hero rings come from + // Settings, and `comparisonBaseline` is web-only — an omission from any + // one surface must not reset a choice made on another. + // + // The preserve set is not a list maintained here: it is derived from + // `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover + // every field of `DashboardLayout`. Adding a layout field without a + // disposition fails typecheck, so the next field cannot silently miss this + // merge the way `comparisonBaseline` did. One stored-layout read covers + // every fallback, and the read is skipped entirely when the client sent + // the full set. + // The Zod shape leaves the per-chart `comparisonBaseline` optional while + // `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the + // serializer fills the gap, so the assertion is safe here exactly as it was + // on the previous per-field cast. + let toSerialize = parsed.data as Partial; + if (layoutNeedsPreserveRead(toSerialize)) { const existing = await prisma.user.findUnique({ where: { id: user.id }, select: { dashboardWidgetsJson: true }, @@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const existingLayout = resolveDashboardLayout( existing?.dashboardWidgetsJson, ); - if (mergedChartOverlayPrefs === undefined) { - mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {}; - } - if (mergedScoreRings === undefined) { - mergedScoreRings = existingLayout.selectedScoreRings; - } - if (mergedHeroRingOrder === undefined) { - mergedHeroRingOrder = existingLayout.heroRingOrder; - } + toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout); } - const normalized = serializeDashboardLayout({ - ...parsed.data, - chartOverlayPrefs: mergedChartOverlayPrefs, - selectedScoreRings: mergedScoreRings, - heroRingOrder: mergedHeroRingOrder, - } as DashboardLayout); + const normalized = serializeDashboardLayout(toSerialize as DashboardLayout); await prisma.user.update({ where: { id: user.id }, diff --git a/src/lib/__tests__/dashboard-layout.test.ts b/src/lib/__tests__/dashboard-layout.test.ts index 8624620f6..8379f5c0c 100644 --- a/src/lib/__tests__/dashboard-layout.test.ts +++ b/src/lib/__tests__/dashboard-layout.test.ts @@ -9,6 +9,10 @@ import { IOS_PIN_ONLY_WIDGET_IDS, DEFAULT_HERO_RING_ORDER, resolveHeroRingOrder, + LAYOUT_FIELD_MERGE_DISPOSITION, + PRESERVED_LAYOUT_FIELDS, + layoutNeedsPreserveRead, + mergePreservedLayoutFields, type DashboardLayout, } from "@/lib/dashboard-layout"; @@ -438,7 +442,7 @@ describe("resolveDashboardLayout() — retired heroVisible field", () => { * iOS-only ids extend it without touching the writable PUT enum. */ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { - it("carries exactly 37 distinct ids (28 server-known + 9 iOS-only)", () => { + it("carries exactly 42 distinct ids (29 server-known + 13 iOS-only)", () => { // v1.11.2 B5 — the 8 v1.10 additive metrics became web-writable, so // the server-known set grew 16 → 24 and the catalogue 27 → 35. // v1.18.2 — the Vorsorge summary widget added one server-known id @@ -450,10 +454,42 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { // v1.29 — the nutrients-store-backed fluid-intake strip tile // (`waterIntake`) added one server-known id, so the catalogue grew // 37 → 38. + // The four clinical signals the native client pins (grip strength, pain + // NRS, waist circumference, waist-to-height) joined the iOS-only set + // (9 → 13), so the catalogue grew 38 → 42. Before they were catalogued + // the widgets PUT dropped them as unknown ids and every native layout + // save lost the user's placement of those tiles. expect(DASHBOARD_WIDGET_IDS).toHaveLength(29); - expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(9); - expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(38); - expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(38); + expect(DASHBOARD_IOS_ONLY_WIDGET_IDS).toHaveLength(13); + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toHaveLength(42); + expect(new Set(DASHBOARD_WIDGET_CATALOGUE_IDS).size).toBe(42); + }); + + it("catalogues the four clinical signals the native client pins", () => { + // Regression guard: these four reached the widgets PUT as unknown ids, + // were filtered out before Zod, and vanished from the persisted layout + // — a silent loss of the user's tile placement on every save. + for (const id of [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]) { + expect(DASHBOARD_WIDGET_CATALOGUE_IDS).toContain(id); + } + }); + + it("keeps the four clinical signals out of the web-writable set", () => { + // The web dashboard has no render path for them — their charts live on + // the /insights sub-pages — so a web Settings toggle would do nothing. + for (const id of [ + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", + ]) { + expect(DASHBOARD_WIDGET_IDS).not.toContain(id); + } }); it("ships the sleep / steps / glucose strip tiles default-on (v1.20.0)", () => { @@ -498,6 +534,9 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { it("carries the locked iOS-only ids verbatim", () => { // v1.28.52 — `hrv` + `respiratoryRate` left this set for the writable // DASHBOARD_WIDGET_IDS (they gained a web strip tile). + // The four clinical signals joined so the widgets PUT stops dropping + // them as unknown ids; `painNRS` is spelled as the native client sends + // it, which differs in case from the `painNrs` chart-overlay slot. expect([...DASHBOARD_IOS_ONLY_WIDGET_IDS]).toEqual([ "restingHeartRate", "walkingSpeed", @@ -508,6 +547,10 @@ describe("DASHBOARD_WIDGET_CATALOGUE_IDS — 27-id catalogue", () => { "walkingDoubleSupport", "audioExposureEnvironment", "audioExposureHeadphone", + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", ]); }); }); @@ -909,3 +952,137 @@ describe("resolveDashboardLayout() / serialize — heroRingOrder", () => { expect(DEFAULT_HERO_RING_ORDER[0]).toBe("HEALTH_SCORE"); }); }); + +/** + * Preserve-when-absent contract for the widgets PUT. + * + * `comparisonBaseline` was a top-level layout field that the merge forgot. + * Because `serializeDashboardLayout` clamps a missing baseline to `"none"`, + * every layout save from a client that doesn't send the field — the native + * client documents it as web-only and never sends it — silently reset the + * comparison baseline the user had picked on the web. No error, no audit row. + * + * The fix is not "remember to add the field to the merge". It is a + * disposition map the type system forces to cover every field of + * `DashboardLayout`, from which the preserve set is derived. These tests pin + * the behaviour that map buys. + */ +describe("layout field merge disposition", () => { + /** + * A layout with EVERY top-level field populated. Typed as + * `Required` so adding a field to the interface without + * adding it here is a type error too — the runtime assertion below then + * proves the disposition map covers it. + */ + const fullyPopulatedLayout: Required = { + version: 1, + widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }], + comparisonBaseline: "lastYear", + chartOverlayPrefs: { + weight: { + showTrendIndicator: true, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + }, + }, + selectedScoreRings: ["MED_COMPLIANCE"], + heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"], + }; + + it("assigns a disposition to every top-level layout field", () => { + // The guard that makes the next field impossible to forget. Adding a + // field to `DashboardLayout` without a disposition fails typecheck at + // the `satisfies Record`; this asserts the + // same coverage at runtime so the failure is loud in the suite too. + expect(Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION).sort()).toEqual( + Object.keys(fullyPopulatedLayout).sort(), + ); + }); + + it("only ever assigns a known disposition", () => { + for (const disposition of Object.values(LAYOUT_FIELD_MERGE_DISPOSITION)) { + expect(["replace", "preserve"]).toContain(disposition); + } + }); + + it("preserves comparisonBaseline alongside the other client-owned fields", () => { + // The regression itself: `comparisonBaseline` must be in the preserve + // set, not merely present in the disposition map. + expect([...PRESERVED_LAYOUT_FIELDS].sort()).toEqual([ + "chartOverlayPrefs", + "comparisonBaseline", + "heroRingOrder", + "selectedScoreRings", + ]); + }); + + it("replaces the fields the request itself owns", () => { + expect(LAYOUT_FIELD_MERGE_DISPOSITION.version).toBe("replace"); + expect(LAYOUT_FIELD_MERGE_DISPOSITION.widgets).toBe("replace"); + }); + + it("skips the stored-layout read when the client sent every preserved field", () => { + expect(layoutNeedsPreserveRead(fullyPopulatedLayout)).toBe(false); + }); + + it("requires the stored-layout read when any preserved field is absent", () => { + for (const field of PRESERVED_LAYOUT_FIELDS) { + const incoming: Partial = { ...fullyPopulatedLayout }; + delete incoming[field]; + expect(layoutNeedsPreserveRead(incoming)).toBe(true); + } + }); + + it("carries an omitted comparisonBaseline forward from the stored layout", () => { + // A widgets-only save, which is exactly what the native client sends. + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + }; + const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout); + expect(merged.comparisonBaseline).toBe("lastYear"); + }); + + it("carries every omitted preserved field forward, one field at a time", () => { + for (const field of PRESERVED_LAYOUT_FIELDS) { + const incoming: Partial = { ...fullyPopulatedLayout }; + delete incoming[field]; + const merged = mergePreservedLayoutFields(incoming, fullyPopulatedLayout); + expect(merged[field]).toEqual(fullyPopulatedLayout[field]); + } + }); + + it("never overwrites a preserved field the client did send", () => { + const stored: DashboardLayout = { + ...fullyPopulatedLayout, + comparisonBaseline: "lastMonth", + selectedScoreRings: ["MED_COMPLIANCE"], + }; + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + comparisonBaseline: "none", + chartOverlayPrefs: fullyPopulatedLayout.chartOverlayPrefs, + selectedScoreRings: [], + heroRingOrder: fullyPopulatedLayout.heroRingOrder, + }; + const merged = mergePreservedLayoutFields(incoming, stored); + // An explicit "none" is a real choice — clearing must survive the merge. + expect(merged.comparisonBaseline).toBe("none"); + expect(merged.selectedScoreRings).toEqual([]); + }); + + it("leaves the replace-fields untouched by the merge", () => { + const stored: DashboardLayout = { + ...fullyPopulatedLayout, + widgets: [{ id: "bp", visible: false, tileVisible: false, order: 0 }], + }; + const incoming: Partial = { + version: 1, + widgets: fullyPopulatedLayout.widgets, + }; + const merged = mergePreservedLayoutFields(incoming, stored); + expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets); + }); +}); diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index dcf249db0..56501338b 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -139,6 +139,27 @@ export const DASHBOARD_IOS_ONLY_WIDGET_IDS = [ "walkingDoubleSupport", "audioExposureEnvironment", "audioExposureHeadphone", + // The four clinical signals the v1.25 sub-pages already chart + // (`/insights/grip-strength`, `/insights/pain`, `/insights/waist`, + // `/insights/waist-to-height`). The native client offers a dashboard tile + // for each; before they joined the catalogue the widgets PUT dropped them + // through the unknown-id filter, so every native layout save silently lost + // the user's placement of those four tiles. + // + // They live here rather than in `DASHBOARD_WIDGET_IDS` because the web + // dashboard has no render path for them — the chart lives on the insights + // sub-page, not on the Startseite. That keeps them out of the web Settings + // list (built from `DASHBOARD_WIDGET_IDS`) while the PUT enum, the on-read + // resolver, and the snapshot catalogue all accept and retain them. + // + // `painNRS` matches the id the native client sends. The sibling + // chart-overlay slot spells it `painNrs` (see `CHART_OVERLAY_KEYS`); the + // two namespaces are independent and neither is derived from the other, so + // the casing difference is deliberate rather than a typo. + "gripStrength", + "painNRS", + "waistCircumference", + "waistToHeight", ] as const; /** @@ -548,6 +569,86 @@ export interface DashboardLayout { heroRingOrder?: HeroRingId[]; } +/** + * How the widgets PUT treats each top-level layout field when the client + * omits it. + * + * - `"replace"` — the field is the point of the request. A PUT that omits it + * is either rejected by Zod (`version`, `widgets` are required) or means + * the caller genuinely has nothing to say. + * - `"preserve"` — the field is owned by a surface OTHER than the one making + * this request, so an omission means "I don't know about this", never + * "clear it". The route reads the stored layout and carries the existing + * value forward. + * + * The preserve set exists because clients legitimately don't know every + * field. The per-chart overlay popover PUTs through + * `/api/dashboard/chart-overlay-prefs`; the hero rings are a Settings-only + * control; `comparisonBaseline` is web-only and the native client documents + * that it never sends it. Without preserve-when-absent, a tile reorder on one + * surface silently resets a choice made on another — which is exactly what + * happened to `comparisonBaseline` before it joined this map: the serializer + * clamps a missing value to `"none"`, so every native layout save wiped the + * baseline the user had picked on the web. + * + * `satisfies Record` is the load-bearing part. + * Adding a field to `DashboardLayout` without adding it here is a TYPE error, + * so the next field cannot repeat the `comparisonBaseline` mistake by + * omission — somebody has to make the decision explicitly. + */ +export const LAYOUT_FIELD_MERGE_DISPOSITION = { + version: "replace", + widgets: "replace", + comparisonBaseline: "preserve", + chartOverlayPrefs: "preserve", + selectedScoreRings: "preserve", + heroRingOrder: "preserve", +} as const satisfies Record; + +export type LayoutFieldMergeDisposition = typeof LAYOUT_FIELD_MERGE_DISPOSITION; + +/** + * The top-level layout fields the widgets PUT carries forward from the stored + * layout when the request omits them. Derived from + * `LAYOUT_FIELD_MERGE_DISPOSITION` so the two can never drift. + */ +export const PRESERVED_LAYOUT_FIELDS = ( + Object.keys(LAYOUT_FIELD_MERGE_DISPOSITION) as Array +).filter( + (field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve", +) as ReadonlyArray; + +/** + * True when the incoming layout omits at least one preserved field, i.e. the + * route has to read the stored layout to merge. Lets the common full-layout + * save from Settings skip the extra query. + */ +export function layoutNeedsPreserveRead( + incoming: Partial, +): boolean { + return PRESERVED_LAYOUT_FIELDS.some((field) => incoming[field] === undefined); +} + +/** + * Carry every omitted preserved field forward from the stored layout. + * Fields the client DID send win untouched; `"replace"` fields are never + * consulted here. One stored-layout read covers all of them. + */ +export function mergePreservedLayoutFields( + incoming: Partial, + existing: DashboardLayout, +): Partial { + const merged: Partial = { ...incoming }; + for (const field of PRESERVED_LAYOUT_FIELDS) { + if (merged[field] === undefined) { + // Each key maps to its own value type; the loop is homogeneous over + // `keyof DashboardLayout`, which TypeScript cannot express per-key. + (merged as Record)[field] = existing[field]; + } + } + return merged; +} + const DASHBOARD_LAYOUT_VERSION = 1; /** From 4ddc08f784b943cc163923fdc0662a273c8a1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:24:07 +0200 Subject: [PATCH 2/4] feat(dashboard): emit mood and BMI on the summary payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both metrics exist in the record but the summary route emitted neither, so a native client could render every home-screen tile except those two. Mood reads the canonical daily series (`buildMoodDailySeries`) — the same rollup-backed aggregation the web mood analytics and the dashboard snapshot render — and emits the newest day's score with a trailing seven-day sparkline. BMI comes from `computeBmi`, the one implementation the web vitals dashboard already renders through the derived-metric dispatch; the route resolves the figure so no client re-derives weight over height squared. Mood gates on the mood module through a synthetic `MOOD_ENTRY` key, the same route `NUTRIENT_WATER` takes, because mood is not a measurement type; without the join the card would ship past a disabled module. BMI belongs to no module and rides through with the other core vitals. The BMI card is omitted rather than emitted empty when the profile has no height or no weight in the window: the card shape carries no honest "unknown" state, and a null value would read as a logging gap rather than a missing profile field. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- src/__tests__/i18n-english-leak-guard.test.ts | 1 + .../summary/__tests__/module-gate.test.ts | 19 ++++ .../dashboard/summary/__tests__/route.test.ts | 107 ++++++++++++++++++ src/app/api/dashboard/summary/route.ts | 89 ++++++++++++++- src/lib/dashboard/widget-modules.ts | 12 ++ 11 files changed, 262 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index ad9268086..fd027918c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -425,7 +425,9 @@ "steps": "Schritte", "totalBodyWater": "Gesamtkörperwasser", "boneMass": "Knochenmasse", - "oxygenSaturation": "Sauerstoffsättigung" + "oxygenSaturation": "Sauerstoffsättigung", + "mood": "Stimmung", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "Schritte", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/en.json b/messages/en.json index c78f6c78c..1460664f9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -425,7 +425,9 @@ "steps": "Steps", "totalBodyWater": "Total Body Water", "boneMass": "Bone Mass", - "oxygenSaturation": "Oxygen Saturation" + "oxygenSaturation": "Oxygen Saturation", + "mood": "Mood", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "steps", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/es.json b/messages/es.json index 7e77e5ed2..08e6a0a86 100644 --- a/messages/es.json +++ b/messages/es.json @@ -425,7 +425,9 @@ "steps": "Pasos", "totalBodyWater": "Agua corporal total", "boneMass": "Masa ósea", - "oxygenSaturation": "Saturación de oxígeno" + "oxygenSaturation": "Saturación de oxígeno", + "mood": "Estado de ánimo", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "pasos", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/fr.json b/messages/fr.json index a390a4c5f..f8e967d66 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -425,7 +425,9 @@ "steps": "Pas", "totalBodyWater": "Eau corporelle totale", "boneMass": "Masse osseuse", - "oxygenSaturation": "Saturation en oxygène" + "oxygenSaturation": "Saturation en oxygène", + "mood": "Humeur", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "pas", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/it.json b/messages/it.json index 827c43c5f..bee89c07c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -425,7 +425,9 @@ "steps": "Passi", "totalBodyWater": "Acqua corporea totale", "boneMass": "Massa ossea", - "oxygenSaturation": "Saturazione di ossigeno" + "oxygenSaturation": "Saturazione di ossigeno", + "mood": "Umore", + "bmi": "IMC" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "passi", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/messages/pl.json b/messages/pl.json index a43e94b7f..70027bd7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -425,7 +425,9 @@ "steps": "Kroki", "totalBodyWater": "Całkowita woda w organizmie", "boneMass": "Masa kostna", - "oxygenSaturation": "Saturacja tlenem" + "oxygenSaturation": "Saturacja tlenem", + "mood": "Nastrój", + "bmi": "BMI" }, "unit": { "weight": "kg", @@ -437,7 +439,9 @@ "steps": "kroki", "totalBodyWater": "kg", "boneMass": "kg", - "oxygenSaturation": "%" + "oxygenSaturation": "%", + "mood": "", + "bmi": "kg/m²" } } }, diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 0dd958db5..ec7d7962e 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -81,6 +81,7 @@ const LEGIT_IDENTICAL = new Set([ // Clinical abbreviations / SI units — identical across locales. "charts.vo2Max", "cycle.insights.crosstab.unitGlucose", + "dashboard.metric.unit.bmi", "dashboard.metric.unit.glucose", "dashboard.vo2Max", "dashboard.vo2MaxShort", diff --git a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts index 577966bf1..dfb98886d 100644 --- a/src/app/api/dashboard/summary/__tests__/module-gate.test.ts +++ b/src/app/api/dashboard/summary/__tests__/module-gate.test.ts @@ -87,6 +87,8 @@ const ALL_KINDS = [ "totalBodyWater", "boneMass", "oxygenSaturation", + "mood", + "bmi", ] as const; function card(kind: string) { @@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => { "totalBodyWater", "boneMass", "oxygenSaturation", + // BMI is derived from weight + the profile height and belongs to no + // module, so it rides through with the other core vitals. + "bmi", ]) { expect(kinds).toContain(core); } }); + it("drops the mood card when the mood module is off", async () => { + // Mood is not a MeasurementType, so it gates through the synthetic + // `MOOD_ENTRY` key rather than a measurement enum value. Without that + // join the card would be unmapped and silently ship past a disabled + // module. + setDisabledModules(["mood"]); + const kinds = await emittedKinds(); + + expect(kinds).not.toContain("mood"); + expect(kinds).toContain("weight"); + expect(kinds).toContain("bmi"); + expect(kinds).toHaveLength(ALL_KINDS.length - 1); + }); + it("gates the cached body, so a toggle takes effect on the next request", async () => { // The analytics LRU is not evicted by a module toggle. The filter must // therefore run on the way out, not inside the cached build. diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 3bf6040e0..1005fd8ea 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// The mood card reads the canonical daily series; its own aggregation is +// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an +// empty series by default so the legacy assertions keep their "no mood" +// expectation, and overridden per test below. +vi.mock("@/lib/analytics/mood-series", () => ({ + buildMoodDailySeries: vi.fn(async () => ({ + entries: [], + summary: null, + entryCount: 0, + source: "live" as const, + })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); @@ -85,6 +98,7 @@ import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => { expect(body.data.compliance.takenToday).toBe(1); }); }); + +/** + * Mood + BMI cards. + * + * Both metrics existed in the record but the summary payload emitted + * neither, so the native home screen could not render those two tiles while + * every other tile worked. Both are resolved server-side from the canonical + * source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the + * client consumes a finished figure instead of re-deriving one. + */ +describe("GET /api/dashboard/summary — mood + BMI cards", () => { + function cardsOf(body: unknown): Array> { + return (body as { data: { metrics: Array> } }).data + .metrics; + } + + it("emits a mood card from the canonical daily series", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [ + { date: "2026-07-01", score: 2, samples: 1 }, + { date: "2026-07-02", score: 3, samples: 2 }, + { date: "2026-07-03", score: 4.5, samples: 1 }, + ], + summary: null, + entryCount: 3, + source: "rollup", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const mood = cardsOf(await res.json()).find((c) => c.kind === "mood"); + + expect(mood).toBeDefined(); + // Headline is the newest day's score, verbatim from the series — the + // route must not re-average or re-round what the helper resolved. + expect(mood?.latestValue).toBe(4.5); + expect(mood?.sparkline).toEqual([2, 3, 4.5]); + expect(mood?.trend).toBe("up"); + expect(mood?.allTimeCount).toBe(3); + expect(mood?.titleKey).toBe("dashboard.metric.title.mood"); + expect(mood?.unitKey).toBe("dashboard.metric.unit.mood"); + expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z"); + }); + + it("omits the mood card when the account has never logged a mood", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe( + undefined, + ); + }); + + it("emits a BMI card computed from the profile height and latest weight", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // 180 cm profile. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: new Date("1986-01-01"), + gender: null, + heightCm: 180, + } as never); + // `computeBmi` reads recent weight rows through `measurement.findMany`. + // 81 kg at 1.80 m => 25.0. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") }, + ] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi"); + + expect(bmi).toBeDefined(); + // The canonical helper's rounding (1 dp), not a second formula. + expect(bmi?.latestValue).toBe(25); + expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi"); + expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi"); + }); + + it("omits the BMI card when the profile carries no height", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Default profile has `heightCm: null`; weight alone cannot yield a BMI. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") }, + ] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe( + undefined, + ); + }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 00b399f98..5dd8a4195 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -74,6 +74,9 @@ import { import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { SleepStage } from "@/generated/prisma/client"; import { loadBaselineProfile } from "@/lib/insights/derived/baseline"; +import { computeBmi } from "@/lib/insights/derived/bmi"; +import { isDerivedOk } from "@/lib/insights/derived/types"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; import { reconstructNights, sleepNeedMinutes, @@ -96,7 +99,9 @@ type MetricKind = | "steps" | "totalBodyWater" | "boneMass" - | "oxygenSaturation"; + | "oxygenSaturation" + | "mood" + | "bmi"; interface MetricCard { id: string; @@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record = { totalBodyWater: "dashboard.metric.title.totalBodyWater", boneMass: "dashboard.metric.title.boneMass", oxygenSaturation: "dashboard.metric.title.oxygenSaturation", + mood: "dashboard.metric.title.mood", + bmi: "dashboard.metric.title.bmi", }; const METRIC_UNIT_KEYS: Record = { @@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record = { totalBodyWater: "dashboard.metric.unit.totalBodyWater", boneMass: "dashboard.metric.unit.boneMass", oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", + mood: "dashboard.metric.unit.mood", + bmi: "dashboard.metric.unit.bmi", }; /** @@ -615,11 +624,24 @@ async function buildDashboardSummary( // before the per-night reconstruction sums it. v1.17.0 — the baseline // profile rides along (one read) so the sleep-rhythm block resolves the // age-based sleep need the cumulative-debt deficit uses. - const [sleepPriorityJson, sleepProfile] = await Promise.all([ + const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([ loadUserSourcePriority(userId), loadBaselineProfile(prisma, userId), + // The mood card reads the SAME canonical daily series the web mood + // analytics and the dashboard snapshot read (`buildMoodDailySeries`), + // rollup fast-path included — never a second aggregation. + time("moodSeries", () => buildMoodDailySeries(userId, prisma)), ]); + // BMI is derived, not measured: it needs the profile height loaded above, + // so it cannot join the round before it. `computeBmi` is the one canonical + // implementation (the same one the web vitals dashboard renders through + // the derived-metric dispatch) — the route resolves the figure and the + // client consumes it, rather than every client re-deriving weight / m². + const bmiDerived = await time("bmi", () => + computeBmi(userId, sleepProfile, { now }), + ); + // Per-type metadata lookup — typed Map so a metric with no readings // at all falls through `metaForType` to the `{ allTimeCount: 0, // lastSeenAt: null }` default. The aggregate row's `_count._all` is @@ -954,6 +976,69 @@ async function buildDashboardSummary( }); } + // Mood — the one metric on this payload that is not a `Measurement`. The + // daily series is the canonical `buildMoodDailySeries` output (rollup + // fast-path, 1..5 scale, one entry per day with an average score), the + // same series the web mood analytics and the dashboard snapshot render. + // Emitted only when the account has ever logged a mood, matching the + // `allTimeCount === 0` gate every optional card above uses. + if (moodSeries.entryCount > 0) { + const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score); + const latestEntry = moodSeries.entries[moodSeries.entries.length - 1]; + // The series carries UTC day labels, not timestamps — the entry's day is + // the only instant the source actually pins, so it is emitted as the + // day's start rather than inventing a reading time. + const latestAt = latestEntry + ? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString() + : null; + metrics.push({ + id: "mood", + kind: "mood", + titleKey: METRIC_TITLE_KEYS.mood, + latestValue: latestEntry?.score ?? null, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS.mood, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latestAt, + allTimeCount: moodSeries.entryCount, + lastSeenAt: latestAt, + }); + } + + // BMI — derived from the latest weight at the profile height by + // `computeBmi`, the single implementation the web vitals dashboard also + // renders. The card is omitted when the derivation is insufficient + // (no height on the profile, or no weight inside its window): the card + // shape has no "unknown" state to express honestly, and a card with a + // null value would read as a logging gap rather than a missing profile + // field. Clients that want to prompt for a height should do so from the + // profile surface, not from an empty dashboard tile. + if (isDerivedOk(bmiDerived)) { + const spark = bmiDerived.value.series.slice(-SPARK_DAYS); + metrics.push({ + id: "bmi", + kind: "bmi", + titleKey: METRIC_TITLE_KEYS.bmi, + latestValue: bmiDerived.value.bmi, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS.bmi, + unit: null, + sleepStages: null, + sleepSourceDiscrepancy: null, + trend: trendOf(spark), + sparkline: spark, + // BMI moves exactly when weight does, so the weight metadata is the + // honest recency signal — there is no separate BMI reading. + updatedAt: metaForType("WEIGHT").lastSeenAt, + allTimeCount: metaForType("WEIGHT").allTimeCount, + lastSeenAt: metaForType("WEIGHT").lastSeenAt, + }); + } + // Shared builder carries the exact tally semantics this tile always // had (scheduled = every today-window row; taken = `takenAt` set and // not skipped). The wire shape of `compliance` is locked for iOS. diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts index ee12a8dfa..3bddac9d8 100644 --- a/src/lib/dashboard/widget-modules.ts +++ b/src/lib/dashboard/widget-modules.ts @@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial> = { // `NutrientIntakeDay` (nutrient="water", summed across sources). Gated // on the `nutrients` module like the rest of that store's surfaces. NUTRIENT_WATER: "nutrients", + // Mood is a `MoodEntry`-derived summary, not a `MeasurementType`, so it + // rides the same synthetic-key route as `NUTRIENT_WATER`. Gated on the + // `mood` module: turning the module off must drop the dashboard card on + // every client, not just hide the web widget. + MOOD_ENTRY: "mood", }; /** @@ -123,6 +128,13 @@ export const SUMMARY_METRIC_TYPE_BY_KIND: Record = { totalBodyWater: "TOTAL_BODY_WATER", boneMass: "BONE_MASS", oxygenSaturation: "OXYGEN_SATURATION", + // Synthetic key — see `MOOD_ENTRY` in `SUMMARY_TYPE_MODULE`. + mood: "MOOD_ENTRY", + // BMI is derived from weight + the profile height and belongs to no + // module, exactly like weight and body fat. Listed so the full emitted + // set stays readable and a future card cannot be added without deciding + // what backs it. + bmi: "WEIGHT", }; /** From 4c3cc84daf8709ab1eb79ee69fded719a6e32634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:28:48 +0200 Subject: [PATCH 3/4] feat(medications): carry manufacturer and printed strength on an inventory container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native pen list renders these two as its headline and subhead. The server held neither, so a container registered on the web could not be shown there without fabricating an empty card, and the client kept its own copy locally with no back-fill possible. Both columns are additive and nullable (migration 0255). Every existing row is truthfully NULL — the values were never collected, and a plain supply row such as a blister pack or a bottle has no use for either. Strength stays free text rather than a parsed number + unit: pens state it per dose, per ml, or per cartridge depending on the product, and forcing a numeric split would lose which of the three was printed. On PATCH an omitted field leaves the stored value untouched, the same rule notes already followed, so a client that does not know the fields cannot blank what another populated; an explicit null still clears. The web add-container form collects both for a PEN, so the columns do not ship empty forever. --- docs/api/openapi.yaml | 40 ++++++++++++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- .../migration.sql | 14 +++++ prisma/schema.prisma | 52 ++++++++++------ .../[itemId]/__tests__/route.test.ts | 61 +++++++++++++++++++ .../[id]/inventory/[itemId]/route.ts | 13 ++++ .../[id]/inventory/__tests__/route.test.ts | 59 ++++++++++++++++++ .../api/medications/[id]/inventory/route.ts | 13 +++- .../sections/inventory-section.tsx | 44 +++++++++++++ .../inventory/__tests__/service.test.ts | 41 +++++++++++++ src/lib/medications/inventory/service.ts | 4 ++ src/lib/openapi/routes/medications/schemas.ts | 12 ++++ src/lib/validations/medication/inventory.ts | 36 +++++++++++ 18 files changed, 391 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e17c3fd7..ca649fc67 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12425,6 +12425,19 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -12458,6 +12471,19 @@ components: type: number minimum: 0 maximum: 1000 + manufacturer: + description: Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars. + anyOf: + - type: string + maxLength: 120 + - type: "null" + doseStrength: + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split + into a number + unit because pens state strength per dose, per ml, or per cartridge. + anyOf: + - type: string + maxLength: 60 + - type: "null" notes: anyOf: - type: string @@ -19420,6 +19446,18 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + manufacturer: + anyOf: + - type: string + - type: "null" + description: Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the + field existed, and for plain supply rows that never carried one. + doseStrength: + anyOf: + - type: string + - type: "null" + description: Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens + state strength per dose, per ml, or per cartridge. NULL when unknown. notes: anyOf: - type: string @@ -19444,6 +19482,8 @@ components: - expiresAt - printedExpiry - purchasedAt + - manufacturer + - doseStrength - notes - createdAt - updatedAt diff --git a/messages/de.json b/messages/de.json index fd027918c..7ad5bd619 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)", "packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.", "packagingSuccess": "Packungsangaben gespeichert", - "packagingFailed": "Speichern fehlgeschlagen" + "packagingFailed": "Speichern fehlgeschlagen", + "addManufacturerLabel": "Hersteller", + "addDoseStrengthLabel": "Wirkstärke", + "addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/en.json b/messages/en.json index 1460664f9..ec57a5d9b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Default pack size (units)", "packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.", "packagingSuccess": "Packaging details saved", - "packagingFailed": "Saving failed" + "packagingFailed": "Saving failed", + "addManufacturerLabel": "Manufacturer", + "addDoseStrengthLabel": "Strength", + "addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml." }, "erweitert": { "group": { diff --git a/messages/es.json b/messages/es.json index 08e6a0a86..8c9a05e17 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)", "packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.", "packagingSuccess": "Datos del envase guardados", - "packagingFailed": "Error al guardar" + "packagingFailed": "Error al guardar", + "addManufacturerLabel": "Fabricante", + "addDoseStrengthLabel": "Concentración", + "addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/fr.json b/messages/fr.json index f8e967d66..4a46342e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Taille de boîte par défaut (unités)", "packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.", "packagingSuccess": "Conditionnement enregistré", - "packagingFailed": "Échec de l’enregistrement" + "packagingFailed": "Échec de l’enregistrement", + "addManufacturerLabel": "Fabricant", + "addDoseStrengthLabel": "Dosage", + "addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/it.json b/messages/it.json index bee89c07c..420f19bb4 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)", "packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.", "packagingSuccess": "Dati confezione salvati", - "packagingFailed": "Salvataggio non riuscito" + "packagingFailed": "Salvataggio non riuscito", + "addManufacturerLabel": "Produttore", + "addDoseStrengthLabel": "Dosaggio", + "addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/messages/pl.json b/messages/pl.json index 70027bd7f..7514d89fb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1687,7 +1687,10 @@ "packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)", "packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.", "packagingSuccess": "Zapisano dane opakowania", - "packagingFailed": "Zapis nie powiódł się" + "packagingFailed": "Zapis nie powiódł się", + "addManufacturerLabel": "Producent", + "addDoseStrengthLabel": "Moc", + "addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml." }, "erweitert": { "group": { diff --git a/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql b/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql new file mode 100644 index 000000000..39170c503 --- /dev/null +++ b/prisma/migrations/0255_medication_inventory_pen_labelling/migration.sql @@ -0,0 +1,14 @@ +-- Carton labelling for an inventory container: manufacturer + printed strength. +-- +-- The native client's pen list uses these two as its headline and subhead, so +-- a container created on the web could not be shown there without fabricating +-- an empty card. Nothing on the server held them, so the client kept its own +-- copy locally and no back-fill was possible. +-- +-- Both are additive and nullable. Every pre-existing row is truthfully NULL: +-- the values were never collected, and a plain supply row (a blister pack, a +-- bottle) has no use for either. No backfill, no default, no rewrite of the +-- table's existing contents. +ALTER TABLE "medication_inventory_items" + ADD COLUMN "manufacturer" TEXT, + ADD COLUMN "dose_strength" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f525464f7..aa60be5ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2861,6 +2861,18 @@ model MedicationInventoryItem { /// The carton-printed expiry date the user enters at creation. printedExpiry DateTime? @map("printed_expiry") purchasedAt DateTime? @map("purchased_at") + /// Marketing-authorisation holder / maker printed on the carton. Free + /// text: the field is a label the user reads back, not a lookup key, and + /// there is no authority list a self-hoster could be held to. Nullable + /// because every pre-existing row predates it and plain supply rows + /// (blisters, bottles) have no use for it. + manufacturer String? + /// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text + /// and deliberately NOT parsed into a number + unit: pens state strength + /// per dose, per ml, or per cartridge depending on the product, and a + /// forced numeric split would lose which of the three it was. Nullable + /// for the same reason as `manufacturer`. + doseStrength String? @map("dose_strength") /// Free-text note, application-layer max 200 chars. notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column @@ -3232,9 +3244,9 @@ model RefreshToken { // the token pair the first exchange issued (refresh-token reuse-detection // posture). 90-second TTL enforced at read; a daily sweep prunes stale rows. model OidcNativeHandoff { - id String @id @default(cuid()) - userId String @map("user_id") - codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) + id String @id @default(cuid()) + userId String @map("user_id") + codeHash String @unique @map("code_hash") // hashToken(hlh_<…>) /// The app's S256 PKCE challenge, bound at mint; the exchange verifies /// s256(code_verifier) against it (constant-time) before consuming. codeChallenge String @map("code_challenge") @@ -3244,8 +3256,8 @@ model OidcNativeHandoff { /// revokes exactly this family member (+ its paired access token). issuedRefreshTokenHash String? @map("issued_refresh_token_hash") createdAt DateTime @default(now()) @map("created_at") - ipAddress String? @map("ip_address") - userAgent String? @map("user_agent") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -6324,14 +6336,14 @@ model DocumentConditionLink { /// versioned key (no schema column), and key rotation re-tokenises from the /// decrypted `textEncrypted`. model DocumentContentIndex { - id String @id @default(cuid()) - documentId String @unique @map("document_id") - userId String @map("user_id") + id String @id @default(cuid()) + documentId String @unique @map("document_id") + userId String @map("user_id") /// AES-256-GCM ciphertext of the normalised extracted text (capped, first /// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared /// with the Coach columns. The plaintext is recoverable server-side so /// rotation can re-tokenise; the list query never selects it. - textEncrypted Bytes @map("text_encrypted") + textEncrypted Bytes @map("text_encrypted") /// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM /// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or /// de-accented), stored additionally alongside `textEncrypted` so a document @@ -6340,22 +6352,22 @@ model DocumentContentIndex { /// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before /// P4 carry NULL until re-indexed, and the chat route falls back to the /// normalised `textEncrypted` when it is absent. The list query never selects it. - verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted") + verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted") /// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind /// index. One-way: opaque without the server-held index subkey. GIN-indexed /// (raw SQL in the migration; Prisma cannot express a GIN index). - searchTokens String[] @default([]) @map("search_tokens") + searchTokens String[] @default([]) @map("search_tokens") /// Provenance of the indexed text: "vision" (provider transcription of the /// stored original), "text-ocr" (browser-OCR text posted by the client), /// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr" /// (server-side OCR of a scanned document — deferred follow-up). Free String; /// adding a source value needs no migration. - source String + source String /// Provider type that produced the text (diagnostic only), or null on the /// text-ocr path. - providerType String? @map("provider_type") + providerType String? @map("provider_type") /// Tokeniser algorithm version — bump to force a re-index on a scheme change. - tokenizerVersion String @map("tokenizer_version") + tokenizerVersion String @map("tokenizer_version") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -6376,23 +6388,23 @@ model DocumentContentIndex { /// source, so the preview is metadata-free by construction. Owner-scoped /// serve route only; cascades on delete. model DocumentThumbnail { - id String @id @default(cuid()) - documentId String @unique @map("document_id") - userId String @map("user_id") + id String @id @default(cuid()) + documentId String @unique @map("document_id") + userId String @map("user_id") /// AES-256-GCM ciphertext of the JPEG preview, stored as the /// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same /// codec `DocumentContentIndex.textEncrypted` uses so the standard /// key-rotation walk covers it. A scanned medical preview is PHI — never /// stored in the clear or logged. The list query never selects it. - thumbnailEncrypted Bytes @map("thumbnail_encrypted") + thumbnailEncrypted Bytes @map("thumbnail_encrypted") /// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer /// size the tile without decoding the blob. width Int height Int /// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`. - byteSize Int @map("byte_size") + byteSize Int @map("byte_size") /// Source MIME the preview was rendered from (diagnostic only). - sourceMime String @map("source_mime") + sourceMime String @map("source_mime") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts index 68bc43f27..ff22da059 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc expect(res.status).toBe(422); }); }); + +describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => { + it("updates manufacturer + doseStrength when supplied", async () => { + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH( + patchReq({ + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + ROUTE_CTX, + ); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect(arg.data.manufacturer).toBe("Example Pharma"); + expect(arg.data.doseStrength).toBe("5 mg/0.5 ml"); + }); + + it("leaves both untouched when the request omits them", async () => { + // Absent must mean "I have nothing to say", never "blank it" — a client + // that does not know the fields must not wipe what another populated. + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect("manufacturer" in arg.data).toBe(false); + expect("doseStrength" in arg.data).toBe(false); + }); + + it("clears a value on an explicit null", async () => { + vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({ + id: "i1", + state: "ACTIVE", + } as never); + + const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX); + + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.medicationInventoryItem.update).mock + .calls[0]?.[0] as unknown as { + data: Record; + }; + expect(arg.data.manufacturer).toBeNull(); + expect("doseStrength" in arg.data).toBe(false); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index df65ba109..449ce8bfd 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -79,6 +79,8 @@ export const PATCH = apiHandler( markAsUsedUp, printedExpiry, unitsRemaining, + manufacturer, + doseStrength, notes, } = parsed.data; @@ -151,6 +153,16 @@ export const PATCH = apiHandler( ? {} : { notesEncrypted: encryptNote(notes), notes: null }; + // Carton labelling follows the same absent-means-untouched rule as + // `notes`: an omitted field must not blank a value the other client + // populated. Built field by field, never spread from the parsed body. + const labellingData: { + manufacturer?: string | null; + doseStrength?: string | null; + } = {}; + if (manufacturer !== undefined) labellingData.manufacturer = manufacturer; + if (doseStrength !== undefined) labellingData.doseStrength = doseStrength; + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { @@ -159,6 +171,7 @@ export const PATCH = apiHandler( unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, + ...labellingData, ...notesData, }, }); diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 78f4f33ed..ecbe8bfa3 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)" expect(body.details.issues.length).toBeGreaterThanOrEqual(3); }); }); + +describe("POST /api/medications/[id]/inventory — carton labelling", () => { + it("persists manufacturer + doseStrength when the client sends them", async () => { + // The native pen list uses these two as its headline and subhead. With + // nowhere on the server to hold them, a container registered on the web + // could not be rendered there at all. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({ + id: "inv-new", + state: "ACTIVE", + } as never); + + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + unitsTotal: 4, + containerType: "PEN", + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + + expect(res.status).toBe(201); + expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + }), + }); + }); + + it("stores null for both when the client omits them", async () => { + // A plain supply row (blister, bottle) carries no carton labelling, and + // every row that predates the columns is truthfully null. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({ + id: "inv-new", + state: "ACTIVE", + } as never); + + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + unitsTotal: 30, + containerType: "BLISTER", + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + + expect(res.status).toBe(201); + expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + manufacturer: null, + doseStrength: null, + }), + }); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index d553ddee7..d22a8572b 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -121,8 +121,15 @@ export const POST = apiHandler( return returnAllZodIssues(parsed.error, 422); } - const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } = - parsed.data; + const { + unitsTotal, + containerType, + printedExpiry, + purchasedAt, + manufacturer, + doseStrength, + notes, + } = parsed.data; const created = await prisma.medicationInventoryItem.create({ // The wire field `unitsTotal` counts UNITS (tablets / ampoules / @@ -135,6 +142,8 @@ export const POST = apiHandler( containerType: containerType ?? "OTHER", printedExpiry: printedExpiry ?? null, purchasedAt: purchasedAt ?? null, + manufacturer: manufacturer ?? null, + doseStrength: doseStrength ?? null, notes: notes ?? null, }), }); diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index fff2c12a0..d473fe8d5 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -492,6 +492,11 @@ export function AddInventoryDialog({ const [containerType, setContainerType] = useState(initialContainerType); const [expiry, setExpiry] = useState(""); + // Carton labelling. Only offered for a PEN: the native pen list is the + // one surface that renders them, and asking for a maker + strength on a + // blister pack would be noise on the far commoner path. + const [manufacturer, setManufacturer] = useState(""); + const [doseStrength, setDoseStrength] = useState(""); const [busy, setBusy] = useState(false); const formId = useId(); @@ -513,6 +518,10 @@ export function AddInventoryDialog({ printedExpiry: expiry ? new Date(`${expiry}T00:00:00`).toISOString() : null, + // Trimmed to null so a field the user opened and left blank stores + // absence rather than an empty string. + manufacturer: manufacturer.trim() || null, + doseStrength: doseStrength.trim() || null, }); await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); @@ -664,6 +673,41 @@ export function AddInventoryDialog({ onChange={setExpiry} /> + {containerType === "PEN" && ( + <> +
+ + setManufacturer(e.target.value)} + /> +
+
+ + setDoseStrength(e.target.value)} + /> +

+ {t("medications.detail.bestand.addDoseStrengthHint")} +

+
+ + )} ); diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 20509e5ae..0c5751b42 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -80,6 +80,8 @@ describe("buildCreateInventoryInput", () => { containerType: "PEN", printedExpiry: printed, purchasedAt: null, + manufacturer: null, + doseStrength: null, notes: null, }); expect(input).toMatchObject({ @@ -100,6 +102,8 @@ describe("buildCreateInventoryInput", () => { containerType: "OTHER", printedExpiry: null, purchasedAt: null, + manufacturer: null, + doseStrength: null, notes: null, }); expect(input.expiresAt).toBeNull(); @@ -157,3 +161,40 @@ describe("serializeInventoryItem (iOS#31)", () => { expect(out).toMatchObject({ id: "i5", state: "ACTIVE" }); }); }); + +describe("buildCreateInventoryInput — carton labelling", () => { + it("carries manufacturer + doseStrength onto the create input", () => { + // The native pen list renders these as its headline and subhead; a + // container created on the web carried neither, so the row could not be + // shown there without fabricating an empty card. + const input = buildCreateInventoryInput({ + userId: "u", + medicationId: "m", + unitsTotal: 4, + containerType: "PEN", + printedExpiry: null, + purchasedAt: null, + manufacturer: "Example Pharma", + doseStrength: "5 mg/0.5 ml", + notes: null, + }); + expect(input.manufacturer).toBe("Example Pharma"); + expect(input.doseStrength).toBe("5 mg/0.5 ml"); + }); + + it("leaves both null for a plain supply container", () => { + const input = buildCreateInventoryInput({ + userId: "u", + medicationId: "m", + unitsTotal: 30, + containerType: "BLISTER", + printedExpiry: null, + purchasedAt: null, + manufacturer: null, + doseStrength: null, + notes: null, + }); + expect(input.manufacturer).toBeNull(); + expect(input.doseStrength).toBeNull(); + }); +}); diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index 13b4c4919..2b77e2e34 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -144,6 +144,8 @@ export function buildCreateInventoryInput(input: { containerType: MedicationContainerType; printedExpiry: Date | null; purchasedAt: Date | null; + manufacturer: string | null; + doseStrength: string | null; notes: string | null; }) { return { @@ -157,6 +159,8 @@ export function buildCreateInventoryInput(input: { printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, expiresAt: computeExpiresAt(null, input.printedExpiry), + manufacturer: input.manufacturer, + doseStrength: input.doseStrength, // Encrypt the free-text note at rest; the plaintext column stays null. notesEncrypted: encryptNote(input.notes), notes: null, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 57df825a7..6e038efaf 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -314,6 +314,18 @@ export const medicationInventoryItemResource = z ), printedExpiry: z.iso.datetime({ offset: true }).nullable(), purchasedAt: z.iso.datetime({ offset: true }).nullable(), + manufacturer: z + .string() + .nullable() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. NULL for a container registered before the field existed, and for plain supply rows that never carried one.", + ), + doseStrength: z + .string() + .nullable() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.', + ), notes: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), updatedAt: z.iso.datetime({ offset: true }), diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts index 5e735b085..1c33a83d0 100644 --- a/src/lib/validations/medication/inventory.ts +++ b/src/lib/validations/medication/inventory.ts @@ -54,6 +54,24 @@ export const createInventoryItemSchema = z .transform((s) => new Date(s)) .nullable() .optional(), + /** Carton labelling — free text, see the schema comment on the model + * for why neither is parsed into a structured value. */ + manufacturer: z + .string() + .max(120) + .nullable() + .optional() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.", + ), + doseStrength: z + .string() + .max(60) + .nullable() + .optional() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.', + ), notes: z.string().max(200).nullable().optional(), }) .meta({ @@ -99,6 +117,24 @@ export const updateInventoryItemSchema = z .describe( "Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).", ), + /** Carton labelling — free text, see the schema comment on the model + * for why neither is parsed into a structured value. */ + manufacturer: z + .string() + .max(120) + .nullable() + .optional() + .describe( + "Marketing-authorisation holder / maker as printed on the carton. Free text, max 120 chars.", + ), + doseStrength: z + .string() + .max(60) + .nullable() + .optional() + .describe( + 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, max 60 chars — deliberately not split into a number + unit because pens state strength per dose, per ml, or per cartridge.', + ), notes: z.string().max(200).nullable().optional(), }) .meta({ From 30954901ac5ec30141d0ef183d260585583debeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 12:44:23 +0200 Subject: [PATCH 4/4] chore: reconcile the catalogue and metric-unit guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget catalogue grew to 42 ids, so the snapshot round-trip count follows. The mood card's unit key carries "/5" rather than an empty string — the locale-integrity guard forbids empty values, and the scale is the honest thing to print next to a 1-to-5 score. It joins the identical-across-locales allowlist alongside the other unit notations. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/__tests__/i18n-english-leak-guard.test.ts | 1 + src/lib/dashboard-layout.ts | 2 -- src/lib/dashboard/__tests__/snapshot.test.ts | 6 +++--- 9 files changed, 10 insertions(+), 11 deletions(-) diff --git a/messages/de.json b/messages/de.json index 7ad5bd619..ae2306d65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/messages/en.json b/messages/en.json index ec57a5d9b..09e95c31b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/messages/es.json b/messages/es.json index 8c9a05e17..67516c0a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/messages/fr.json b/messages/fr.json index 4a46342e9..27211eaf0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/messages/it.json b/messages/it.json index 420f19bb4..42060354e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/messages/pl.json b/messages/pl.json index 7514d89fb..b85c044dd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -440,7 +440,7 @@ "totalBodyWater": "kg", "boneMass": "kg", "oxygenSaturation": "%", - "mood": "", + "mood": "/5", "bmi": "kg/m²" } } diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index ec7d7962e..ff16c934f 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -82,6 +82,7 @@ const LEGIT_IDENTICAL = new Set([ "charts.vo2Max", "cycle.insights.crosstab.unitGlucose", "dashboard.metric.unit.bmi", + "dashboard.metric.unit.mood", "dashboard.metric.unit.glucose", "dashboard.vo2Max", "dashboard.vo2MaxShort", diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 56501338b..54447cc5d 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -605,8 +605,6 @@ export const LAYOUT_FIELD_MERGE_DISPOSITION = { heroRingOrder: "preserve", } as const satisfies Record; -export type LayoutFieldMergeDisposition = typeof LAYOUT_FIELD_MERGE_DISPOSITION; - /** * The top-level layout fields the widgets PUT carries forward from the stored * layout when the request omits them. Derived from diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index f267aa23e..19ef63e60 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -752,11 +752,11 @@ describe("buildDashboardSnapshot — layoutCatalogue (35-id round-trip)", () => isFullyCovered.mockReturnValue(false); }); - it("emits all 38 catalogue ids with visibility + order", async () => { + it("emits all 42 catalogue ids with visibility + order", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.layoutCatalogue).toHaveLength(38); + expect(snap.layoutCatalogue).toHaveLength(42); const ids = new Set(snap.layoutCatalogue.map((w) => w.id)); - expect(ids.size).toBe(38); + expect(ids.size).toBe(42); // iOS-only ids appended default-invisible. const walkingSpeed = snap.layoutCatalogue.find( (w) => w.id === "walkingSpeed",