mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/work/parity-contracts' into release/v1.30.34
This commit is contained in:
commit
d68a7ca244
28 files changed
+1087
-86
No files matched your search
@@ -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
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
+10
-3
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
+32
-20
@@ -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")
|
||||
|
||||
@@ -81,6 +81,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// 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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
return (body as { data: { metrics: Array<Record<string, unknown>> } }).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<MetricKind, string> = {
|
||||
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<MetricKind, string> = {
|
||||
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<DashboardLayout>;
|
||||
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 },
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
expect(arg.data.manufacturer).toBeNull();
|
||||
expect("doseStrength" in arg.data).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -492,6 +492,11 @@ export function AddInventoryDialog({
|
||||
const [containerType, setContainerType] =
|
||||
useState<ContainerType>(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}
|
||||
/>
|
||||
</div>
|
||||
{containerType === "PEN" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-manufacturer"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addManufacturerLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-manufacturer"
|
||||
value={manufacturer}
|
||||
maxLength={120}
|
||||
onChange={(e) => setManufacturer(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-dose-strength"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addDoseStrengthLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-add-dose-strength"
|
||||
value={doseStrength}
|
||||
maxLength={60}
|
||||
onChange={(e) => setDoseStrength(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.detail.bestand.addDoseStrengthHint")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -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<DashboardLayout>` 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<DashboardLayout> = {
|
||||
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<keyof DashboardLayout, …>`; 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<DashboardLayout> = { ...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<DashboardLayout> = {
|
||||
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<DashboardLayout> = { ...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<DashboardLayout> = {
|
||||
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<DashboardLayout> = {
|
||||
version: 1,
|
||||
widgets: fullyPopulatedLayout.widgets,
|
||||
};
|
||||
const merged = mergePreservedLayoutFields(incoming, stored);
|
||||
expect(merged.widgets).toEqual(fullyPopulatedLayout.widgets);
|
||||
});
|
||||
});
|
||||
@@ -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<keyof DashboardLayout, …>` 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<keyof DashboardLayout, "replace" | "preserve">;
|
||||
|
||||
/**
|
||||
* 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<keyof DashboardLayout>
|
||||
).filter(
|
||||
(field) => LAYOUT_FIELD_MERGE_DISPOSITION[field] === "preserve",
|
||||
) as ReadonlyArray<keyof DashboardLayout>;
|
||||
|
||||
/**
|
||||
* 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<DashboardLayout>,
|
||||
): 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<DashboardLayout>,
|
||||
existing: DashboardLayout,
|
||||
): Partial<DashboardLayout> {
|
||||
const merged: Partial<DashboardLayout> = { ...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<string, unknown>)[field] = existing[field];
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -74,6 +74,11 @@ export const SUMMARY_TYPE_MODULE: Partial<Record<string, ModuleKey>> = {
|
||||
// `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<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user