diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ae4c1e843..0da18eaf0 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 @@ -19421,6 +19447,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 @@ -19445,6 +19483,8 @@ components: - expiresAt - printedExpiry - purchasedAt + - manufacturer + - doseStrength - notes - createdAt - updatedAt diff --git a/messages/de.json b/messages/de.json index 62cc28f14..7bbc97d04 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 09e242bc9..2995936fd 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 41fad56b4..e3934d442 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 9a78d619b..9c57bb085 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 d01e4b804..ca31566c5 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 dbf403447..2635cfae4 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": "/5", + "bmi": "kg/m²" } } }, @@ -1683,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 ffaaff10a..4bc525cca 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2871,6 +2871,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 @@ -3242,9 +3254,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") @@ -3254,8 +3266,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) @@ -6334,14 +6346,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 @@ -6350,22 +6362,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") @@ -6386,23 +6398,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/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 0dd958db5..ff16c934f 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -81,6 +81,8 @@ 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.mood", "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/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/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/__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..54447cc5d 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,84 @@ 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; + +/** + * 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; /** 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", 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", }; /** 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({