From f4d5114dcd40bd225b5a4b0dd73de50356ade016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:57:01 +0200 Subject: [PATCH 01/11] fix(insights): regenerate cached assessments when a measurement invalidates them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurement-sync invalidation deleted the dirtied per-scope assessment rows and waited for the user's next category open (a cache miss → a worker round-trip while they wait) or the nightly cron to re-warm them. Enqueue a debounced regenerate for each dirtied scope instead, coalesced per (user, scope, locale) through the queue's singleton key so the constant Apple-Health sync collapses a burst of samples into one regenerate per scope rather than a job storm. --- .../invalidate-status-insights.test.ts | 40 +++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 17 ++++++++ 2 files changed, 57 insertions(+) diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 90c68a940..5412fcfec 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); +const enqueueStatusGeneration = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { @@ -18,13 +19,29 @@ vi.mock("@/lib/db", () => ({ }, })); +vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ + enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), +})); + import { invalidateStatusInsightsForTypes } from "../comprehensive-generate"; beforeEach(() => { vi.clearAllMocks(); deleteMany.mockResolvedValue({ count: 0 }); + enqueueStatusGeneration.mockResolvedValue(undefined); }); +/** Distinct scopes the invalidator enqueued a regenerate for. */ +function enqueuedScopes(): string[] { + return [ + ...new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { metric: string }).metric, + ), + ), + ].sort(); +} + function deletedScopes(): string[] { const arg = deleteMany.mock.calls[0][0]; // Each OR clause is `{ action: { startsWith: "insights.-status." } }`. @@ -80,5 +97,28 @@ describe("invalidateStatusInsightsForTypes", () => { it("is a no-op for an empty type set (no DB call)", async () => { await invalidateStatusInsightsForTypes("u1", []); expect(deleteMany).not.toHaveBeenCalled(); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("enqueues a debounced regenerate for every dirtied scope, both locales", async () => { + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + // weight + bmi + general, each warmed for de + en. + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(6); + const locales = new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { locale: string }).locale, + ), + ); + expect([...locales].sort()).toEqual(["de", "en"]); + }); + + it("does not blanket-evict general for an unrelated synced type", async () => { + // A steps sample touches only the general overview, never the weight / + // pulse / bp scopes — the constant Apple-Health sync must not thrash + // every card's cache. + await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); + expect(deletedScopes()).toEqual(["general"]); + expect(enqueuedScopes()).toEqual(["general"]); }); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 040cadd9a..ba175209d 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -47,6 +47,7 @@ import { runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; +import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -231,6 +232,22 @@ export async function invalidateStatusInsightsForTypes( })), }, }); + + // v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left + // the card to re-warm only on the user's next category open (a miss → a + // worker round-trip while they wait) or the nightly cron. Instead, + // proactively enqueue a debounced regenerate for each dirtied scope so + // the cache is re-warmed in the background. The enqueue is coalesced per + // `(user, metric, locale)` via the queue's `singletonKey` (120 s window), + // so the constant Apple-Health sync cannot fan out a storm of jobs — a + // burst of samples collapses into one regenerate per scope per locale. + // The stale-while-revalidate read keeps the previous assessment visible + // until the fresh one lands, so the user never waits on a card. + for (const scope of scopes) { + for (const locale of ["de", "en"] as const) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } + } } interface GenerateOptions { From 60f347bf8df43571c837ea923c93469977d95a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:57:06 +0200 Subject: [PATCH 02/11] feat(insights): serve assessments stale-while-revalidate on a cache miss A category open whose today-cache had been evicted dropped to the preparing skeleton even when a perfectly good assessment from an earlier day was still on record. Read the most recent non-stub assessment for the scope and serve it instantly while the worker re-warms the cache; only fall back to the preparing state when no assessment was ever produced. The user no longer waits on an open if an assessment ever existed. --- .../insights/__tests__/status-cache.test.ts | 38 ++++++++-- src/lib/insights/blood-pressure-status.ts | 13 ++-- src/lib/insights/bmi-status.ts | 13 ++-- src/lib/insights/general-status.ts | 13 ++-- .../insights/medication-compliance-status.ts | 13 ++-- src/lib/insights/mood-status.ts | 13 ++-- src/lib/insights/pulse-status.ts | 13 ++-- src/lib/insights/status-cache.ts | 71 +++++++++++++++++-- src/lib/insights/weight-status.ts | 13 ++-- 9 files changed, 154 insertions(+), 46 deletions(-) diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index fbd4e98c7..c516c031f 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn() }, + auditLog: { findFirst: vi.fn(), findMany: vi.fn() }, }, })); @@ -137,6 +137,11 @@ describe("readFreshStatusText", () => { }); describe("resolveReadOnlyStatusMiss", () => { + beforeEach(() => { + // Default: no prior assessment to serve stale (readLastGoodStatusText). + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + }); + it("returns no-provider without enqueuing when the user has no provider", async () => { hasUsableStatusProvider.mockResolvedValue(false); const outcome = await resolveReadOnlyStatusMiss({ @@ -144,7 +149,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "weight", locale: "en", }); - expect(outcome).toBe("no-provider"); + expect(outcome.kind).toBe("no-provider"); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -157,7 +162,8 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "pulse", locale: "de", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); + expect(outcome).toEqual({ kind: "preparing", lastGood: null }); expect(enqueueStatusGeneration).toHaveBeenCalledWith({ userId: "u1", metric: "pulse", @@ -165,6 +171,28 @@ describe("resolveReadOnlyStatusMiss", () => { }); }); + it("serves the last good assessment stale-while-revalidate on a clean miss", async () => { + hasUsableStatusProvider.mockResolvedValue(true); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + // A prior (e.g. yesterday's) real assessment is on record. + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ + cacheRow( + { dateKey: "2026-05-30", text: "Steady upward trend.", model: "gpt-4o-mini" }, + new Date("2026-05-30T04:30:00.000Z"), + ), + ] as never); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(outcome.kind).toBe("preparing"); + if (outcome.kind !== "preparing") throw new Error("expected preparing"); + expect(outcome.lastGood?.text).toBe("Steady upward trend."); + // A refresh is still enqueued behind the stale serve. + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); + }); + it("suppresses re-enqueue while a fresh timeout stub exists", async () => { hasUsableStatusProvider.mockResolvedValue(true); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( @@ -180,7 +208,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "mood", locale: "en", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -199,7 +227,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "bmi", locale: "de", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 2b10e3f7b..f9d52fdcd 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -134,7 +134,7 @@ export async function generateBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyBloodPressureStatusText(locale), @@ -142,12 +142,15 @@ export async function generateBloodPressureStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index a455fc2eb..a3acf743d 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -72,7 +72,7 @@ export async function generateBmiStatusForUser( metric: "bmi", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyBmiStatusText(locale), @@ -80,12 +80,15 @@ export async function generateBmiStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 1693646f1..6c5914b91 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -87,7 +87,7 @@ export async function generateGeneralStatusForUser( metric: "general", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), @@ -95,12 +95,15 @@ export async function generateGeneralStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 4c69084ff..f1cc966ee 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -121,7 +121,7 @@ export async function generateMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, summary: getNoKeyMedicationComplianceStatusText(locale), @@ -130,13 +130,16 @@ export async function generateMedicationComplianceStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good narrative (if + // any) while the worker re-warms; only show the empty preparing + // skeleton when none was ever produced. return { hasProvider: true, - summary: null, + summary: outcome.lastGood?.text ?? null, medications: [], - cached: false, - updatedAt: null, - preparing: true, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index d54173f17..2a99246de 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -83,7 +83,7 @@ export async function generateMoodStatusForUser( metric: "mood", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyMoodStatusText(locale), @@ -91,12 +91,15 @@ export async function generateMoodStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index ee52eab81..9300e66b8 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -78,7 +78,7 @@ export async function generatePulseStatusForUser( metric: "pulse", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyPulseStatusText(locale), @@ -86,12 +86,15 @@ export async function generatePulseStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment (if + // any) instantly while the worker re-warms the cache; only fall to the + // empty preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index ca6b187a5..fc9e6e687 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -101,13 +101,62 @@ export async function readFreshStatusText(args: { } } +export interface LastGoodStatusHit { + text: string; + updatedAt: string; +} + +/** + * v1.8.7 — read the most recent NON-stub assessment for `(userId, + * cacheAction)` regardless of which day it was generated. This is the + * stale-while-revalidate source: when today's cache is a miss the + * read-only path can still serve yesterday's (or older) good text + * instantly while a fresh generation is warmed out of band, so opening a + * category never drops to the "preparing" skeleton if an assessment was + * ever produced. Returns `null` only when there is genuinely no prior + * assessment (or every prior row is a timeout stub / malformed). + */ +export async function readLastGoodStatusText(args: { + userId: string; + cacheAction: string; +}): Promise { + const { userId, cacheAction } = args; + const rows = await prisma.auditLog.findMany({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { createdAt: true, details: true }, + }); + for (const row of rows) { + if (!row.details) continue; + try { + const parsed = JSON.parse(row.details) as ParsedStatusCache; + if (isTimeoutStub(parsed)) continue; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + continue; + } + return { text: parsed.text, updatedAt: row.createdAt.toISOString() }; + } catch { + // Malformed payload — skip and look further back. + continue; + } + } + return null; +} + /** * Outcome of the read-only cache-miss resolution. The generators map this * onto their public return shape: * - `no-provider` → `{ hasProvider: false, text: }` - * - `preparing` → `{ hasProvider: true, text: null, preparing: true }` + * - `preparing` → `{ hasProvider: true, text: , preparing: true }` + * + * v1.8.7 — `preparing` now carries the last good assessment (if any) so + * the card renders the previous text immediately (stale-while-revalidate) + * instead of a skeleton while the worker re-warms the cache. */ -export type ReadOnlyMissOutcome = "no-provider" | "preparing"; +export type ReadOnlyMissOutcome = + | { kind: "no-provider" } + | { kind: "preparing"; lastGood: LastGoodStatusHit | null }; /** * v1.8.3 — resolve what a read-only status generation should return on a @@ -131,10 +180,20 @@ export async function resolveReadOnlyStatusMiss(args: { locale: "de" | "en"; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); - if (!hasProvider) return "no-provider"; + if (!hasProvider) return { kind: "no-provider" }; const cacheAction = `insights.${args.metric}-status.${args.locale}`; + // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) + // assessment for this scope so the card renders the previous text + // immediately instead of a skeleton while a refresh is warmed. Null when + // no assessment was ever produced — only then does the card show + // "preparing"/"no analysis yet". + const lastGood = await readLastGoodStatusText({ + userId: args.userId, + cacheAction, + }); + // v1.8.3 — honour the short-TTL negative cache. If the worker recently // hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every // navigation while the provider is still degraded would be a storm. While @@ -145,7 +204,7 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, }); - return "preparing"; + return { kind: "preparing", lastGood }; } // Enqueue out of band — do NOT await an LLM here. The enqueue itself is @@ -157,9 +216,9 @@ export async function resolveReadOnlyStatusMiss(args: { }); annotate({ action: { name: "insights.status.preparing" }, - meta: { metric: args.metric }, + meta: { metric: args.metric, stale_served: lastGood !== null }, }); - return "preparing"; + return { kind: "preparing", lastGood }; } /** diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index efd4d368a..b34580c3a 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -132,7 +132,7 @@ export async function generateWeightStatusForUser( metric: "weight", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyWeightStatusText(locale), @@ -140,12 +140,15 @@ export async function generateWeightStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } From 9e8b536ff615de27d7371cea85aec68679c844f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:57:28 +0200 Subject: [PATCH 03/11] fix(insights): place the mood assessment under the first chart The mood assessment rendered last on the page, below the target summary, the calendar, the distribution, the weekday and correlation breakdowns. Move it directly under the mood chart so the narration sits next to the trend it describes, matching the other category pages. --- src/app/insights/mood/page.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 6e42bea95..a21ea684c 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -106,10 +106,9 @@ export default function InsightsStimmungPage() { userTimezone={user?.timezone} /> - - - - + {/* v1.8.7 — the AI assessment reads best directly under the first + chart: the reader sees the trend, then the narration of it, + before the calendar / distribution / correlation breakdowns. */} } @@ -120,6 +119,10 @@ export default function InsightsStimmungPage() { loading={isStatusLoading} preparing={status?.preparing ?? false} /> + + + + ); } From 28af13869109adaf542c387a340f25b09cdb979f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:58:22 +0200 Subject: [PATCH 04/11] feat(medications): show the GLP-1 drug-level estimate by default Render the estimated drug-level-in-blood curve for any recognised GLP-1 medication without requiring the Research Mode opt-in. The chart still self-gates on a recognised brand and on having intake data, and the pharmacokinetic-estimate disclaimer is now always attached so the curve reads as a modelled value, not a measured blood concentration. Drop the now-dead Research-Mode gated-placeholder branch and its copy. The researchModeEnabled flag is unchanged; it still gates the advanced settings surface and its acknowledgment dialog. --- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- src/components/medications/DrugLevelChart.tsx | 112 +++---------- .../__tests__/DrugLevelChart.test.tsx | 157 +++++++++--------- 8 files changed, 105 insertions(+), 212 deletions(-) diff --git a/messages/de.json b/messages/de.json index b7043af03..dd3303220 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1127,11 +1127,6 @@ "emptyState": "Noch keine Einnahmen erfasst.", "emptyStateCta": "Trage eine Dosis ein, damit die geschätzte Verlaufskurve Gestalt annimmt.", "estimateNote": "Edukative Schätzung aus EMA-publizierter Populations-Pharmakokinetik. Keine Messung.", - "gatedTitle": "Forschungsmodus ist aus", - "gatedBody": "Aktiviere den Forschungsmodus in den Einstellungen, um die geschätzte Wirkstoffkurve zu sehen. Die Kurve ist opt-in, weil sie eine Lernhilfe und keine klinische Messung ist.", - "gatedStaleTitle": "Disclaimer muss neu bestätigt werden", - "gatedStaleBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn in den Einstellungen erneut und bestätige, um die Kurve wieder einzublenden.", - "gatedCta": "Forschungsmodus in den Einstellungen öffnen", "unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Zurück zum Medikament", "subtitle": "Vollständiger Einnahmeverlauf", - "plannedSuffix": "geplant", - "drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen" + "plannedSuffix": "geplant" } }, "logInjectionSiteTitle": "Injektionsstelle erfassen", diff --git a/messages/en.json b/messages/en.json index ab4fb72b3..94ec0d550 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1127,11 +1127,6 @@ "emptyState": "No intake events yet.", "emptyStateCta": "Log a dose to see the estimated level take shape.", "estimateNote": "Educational estimate from EMA-published population pharmacokinetics. Not a measurement.", - "gatedTitle": "Research Mode is off", - "gatedBody": "Turn on Research Mode in Settings to see the estimated drug-level curve. The curve is opt-in because it is a learning aid, not a clinical measurement.", - "gatedStaleTitle": "Disclaimer needs re-acknowledgment", - "gatedStaleBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and confirm in Settings to bring the chart back.", - "gatedCta": "Open Research Mode in Settings", "unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Back to medication", "subtitle": "Full intake history", - "plannedSuffix": "planned", - "drugLevelDisclosure": "Show estimated drug-level curve" + "plannedSuffix": "planned" } }, "logInjectionSiteTitle": "Log injection site", diff --git a/messages/es.json b/messages/es.json index 7ae81e64d..4953bba1d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1127,11 +1127,6 @@ "emptyState": "Sin tomas registradas todavía.", "emptyStateCta": "Registra una dosis para ver la curva estimada cobrar forma.", "estimateNote": "Estimación educativa a partir de la farmacocinética poblacional publicada por la EMA. No es una medición.", - "gatedTitle": "Modo Investigación desactivado", - "gatedBody": "Activa el modo Investigación en los ajustes para ver la curva estimada del nivel de fármaco. La curva es de inclusión voluntaria porque es una ayuda de aprendizaje, no una medición clínica.", - "gatedStaleTitle": "El disclaimer requiere reconfirmación", - "gatedStaleBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo en los ajustes y confirma de nuevo para volver a ver la curva.", - "gatedCta": "Abrir el modo Investigación en los ajustes", "unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Volver al medicamento", "subtitle": "Historial completo de tomas", - "plannedSuffix": "planificado", - "drugLevelDisclosure": "Mostrar curva estimada del principio activo" + "plannedSuffix": "planificado" } }, "logInjectionSiteTitle": "Registrar sitio de inyección", diff --git a/messages/fr.json b/messages/fr.json index 30635a52f..d79dcf777 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1127,11 +1127,6 @@ "emptyState": "Aucune prise enregistrée.", "emptyStateCta": "Enregistrez une dose pour voir la courbe estimée prendre forme.", "estimateNote": "Estimation éducative à partir de la pharmacocinétique de population publiée par l'EMA. Pas une mesure.", - "gatedTitle": "Mode Recherche désactivé", - "gatedBody": "Activez le mode Recherche dans les paramètres pour voir la courbe estimée du niveau de médicament. La courbe est en opt-in parce que c'est un outil d'apprentissage, pas une mesure clinique.", - "gatedStaleTitle": "Le disclaimer doit être reconfirmé", - "gatedStaleBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le dans les paramètres et confirmez à nouveau pour réafficher la courbe.", - "gatedCta": "Ouvrir le mode Recherche dans les paramètres", "unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Retour au médicament", "subtitle": "Historique complet des prises", - "plannedSuffix": "prévu", - "drugLevelDisclosure": "Afficher la courbe estimée du principe actif" + "plannedSuffix": "prévu" } }, "logInjectionSiteTitle": "Enregistrer le site d'injection", diff --git a/messages/it.json b/messages/it.json index dba26913b..528df33c0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1127,11 +1127,6 @@ "emptyState": "Nessuna assunzione registrata.", "emptyStateCta": "Registra una dose per vedere prendere forma la curva stimata.", "estimateNote": "Stima educativa dalla farmacocinetica di popolazione pubblicata dall'EMA. Non è una misura.", - "gatedTitle": "Modalità Ricerca disattivata", - "gatedBody": "Attiva la modalità Ricerca nelle impostazioni per vedere la curva stimata del livello del farmaco. La curva è opt-in perché è uno strumento di apprendimento, non una misura clinica.", - "gatedStaleTitle": "Il disclaimer richiede una nuova conferma", - "gatedStaleBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo nelle impostazioni e conferma di nuovo per rivedere la curva.", - "gatedCta": "Apri la modalità Ricerca nelle impostazioni", "unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Torna al farmaco", "subtitle": "Cronologia completa delle assunzioni", - "plannedSuffix": "pianificato", - "drugLevelDisclosure": "Mostra la curva stimata del principio attivo" + "plannedSuffix": "pianificato" } }, "logInjectionSiteTitle": "Registra il sito di iniezione", diff --git a/messages/pl.json b/messages/pl.json index b5cd296ad..1f43336f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1127,11 +1127,6 @@ "emptyState": "Brak zarejestrowanych przyjęć.", "emptyStateCta": "Zarejestruj dawkę, aby zobaczyć kształtującą się krzywą szacunkową.", "estimateNote": "Edukacyjne oszacowanie z opublikowanej przez EMA farmakokinetyki populacyjnej. To nie jest pomiar.", - "gatedTitle": "Tryb Badawczy wyłączony", - "gatedBody": "Włącz tryb Badawczy w Ustawieniach, aby zobaczyć szacowaną krzywą poziomu leku. Krzywa jest opt-in, ponieważ to pomoc edukacyjna, a nie pomiar kliniczny.", - "gatedStaleTitle": "Disclaimer wymaga ponownego potwierdzenia", - "gatedStaleBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie w Ustawieniach i potwierdź, aby krzywa znów się pojawiła.", - "gatedCta": "Otwórz tryb Badawczy w Ustawieniach", "unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej." } }, @@ -1306,8 +1301,7 @@ "history": { "back": "Powrót do leku", "subtitle": "Pełna historia przyjęć", - "plannedSuffix": "zaplanowane", - "drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku" + "plannedSuffix": "zaplanowane" } }, "logInjectionSiteTitle": "Zarejestruj miejsce iniekcji", diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index e414c2bbe..657ff7a29 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -1,11 +1,13 @@ "use client"; /** - * v1.4.25 W19c-Frontend — Estimated drug-level chart for GLP-1 - * medications. Opt-in behind Research Mode (the acknowledgment dialog - * in this directory gates entry); even when enabled the chart paints - * "Estimated level (relative)" on the y-axis without tick labels — - * research §2.3 + W19c-Backend round report carry the rationale. + * Estimated drug-level chart for GLP-1 medications. Visible by default + * for any recognised GLP-1 drug — the curve is the modelled + * active-ingredient level the medication concerns. The y-axis paints + * "Estimated level (relative)" without tick labels and the persistent + * estimate disclaimer makes the pharmacokinetic-ESTIMATE framing + * explicit: this is a modelled value, not a measured blood + * concentration. * * Inputs (read-only): * - Drug id resolved from the medication name (catalog lookup via @@ -18,10 +20,6 @@ * applicable dose by walking the dose-change timeline; intakes * that pre-date every dose-change row fall back to the * medication's headline dose string. - * - Research-mode state from `GET /api/auth/me/research-mode`. The - * chart gates on `enabled === true && acknowledgedVersion === - * currentDisclaimerVersion` — defence-in-depth alongside the - * server-side 400 the POST returns for stale versions. * * Pure math: `computeOneCompartment(drug, doses, asOf)` from * `glp1-pk.ts`. The chart renders a 21-day window so three weekly @@ -38,8 +36,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import Link from "next/link"; -import { AlertTriangle, Loader2, Syringe } from "lucide-react"; +import { Loader2, Syringe } from "lucide-react"; import { Area, AreaChart, @@ -50,7 +47,6 @@ import { YAxis, } from "recharts"; -import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { @@ -62,7 +58,6 @@ import { type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; import { parseDoseMg } from "@/lib/medications/dose-string"; -import type { ResearchModeStatus } from "@/lib/medications/research-mode-types"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; @@ -128,23 +123,6 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { [medication.name], ); - const { data: researchMode, isLoading: rmLoading } = - useQuery({ - queryKey: queryKeys.researchMode(), - queryFn: async () => { - const res = await fetch("/api/auth/me/research-mode"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ResearchModeStatus; - }, - staleTime: 60 * 1000, - }); - - const versionsAligned = - !!researchMode && - researchMode.acknowledgedVersion === researchMode.currentDisclaimerVersion; - const gateOpen = !!researchMode?.enabled && versionsAligned; - const { data: details, isLoading: detailsLoading } = useQuery({ queryKey: queryKeys.medicationGlp1Details(medication.id), @@ -154,9 +132,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const json = await res.json(); return json.data as Glp1DetailsResponse; }, - // Only fetch when the user has opted in; saves a round-trip for - // anyone who hasn't enabled Research Mode. - enabled: gateOpen && !!drugId, + enabled: !!drugId, staleTime: 60 * 1000, }); @@ -170,7 +146,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { const json = await res.json(); return json.data as { events: IntakeEvent[] }; }, - enabled: gateOpen && !!drugId, + enabled: !!drugId, staleTime: 60 * 1000, }); @@ -194,7 +170,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { // ── Section header always renders so the parent's "Estimated drug // level" anchor stays stable; the body switches between gated / // loading / empty / chart states. - const isLoading = rmLoading || (gateOpen && (detailsLoading || intakeLoading)); + const isLoading = !!drugId && (detailsLoading || intakeLoading); const hasDoses = doses.length > 0; const animationsEnabled = !prefersReducedMotion(); // Single-window contract — three weekly cycles with the sample step @@ -214,19 +190,12 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { ) : null; // Decision tree: - // 1. drug not in catalog → gated-unknown-drug placeholder - // 2. !researchMode.enabled OR → gated placeholder + Settings CTA - // versions !== aligned (the defence-in-depth rule) - // 3. loading → skeleton - // 4. no intake events → empty-state with log CTA - // 5. otherwise → AreaChart + // 1. drug not in catalog → unknown-drug placeholder + // 2. loading → skeleton + // 3. no intake events → empty-state with log CTA + // 4. otherwise → AreaChart const body = !drugId ? ( - ) : !gateOpen ? ( - ) : isLoading ? ( // v1.4.43 W11-L6 — match the loaded chart height (240 px) so the // dashboard tile doesn't reflow when the levels render. @@ -257,7 +226,10 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { dataSlot="drug-level-chart" > {body} - {gateOpen && hasDoses && ( + {/* The pharmacokinetic-ESTIMATE disclaimer stays attached whenever + a recognised GLP-1 drug renders the chart — the curve is a + modelled value, never a measured blood concentration. */} + {!!drugId && (

-

-
- - - ); -} function EmptyState() { const { t } = useTranslations(); diff --git a/src/components/medications/__tests__/DrugLevelChart.test.tsx b/src/components/medications/__tests__/DrugLevelChart.test.tsx index 372973b33..684ce4671 100644 --- a/src/components/medications/__tests__/DrugLevelChart.test.tsx +++ b/src/components/medications/__tests__/DrugLevelChart.test.tsx @@ -1,15 +1,16 @@ /** - * v1.4.25 W19c-Frontend — Drug-level chart tests. + * Drug-level chart tests. * - * Pins the gating decision tree (research §2.3 + §12.4 + W19c-Backend - * phase report). The chart MUST hide until: - * - the medication's brand resolves to a Glp1DrugId, AND - * - `enabled === true` AND - * - `acknowledgedVersion === currentDisclaimerVersion`. + * The estimated drug-level curve is visible by default for any + * recognised GLP-1 brand — it does NOT require Research Mode. The + * decision tree is now: + * - the medication's brand resolves to a Glp1DrugId → render, ELSE + * the unknown-drug placeholder. + * - no intake events → empty state. + * - otherwise → the AreaChart, always with the pharmacokinetic + * estimate disclaimer attached. * - * Each gate failure renders a distinct placeholder so the SSR markup - * carries an unambiguous data-slot for assertion. Recharts in - * `renderToStaticMarkup` only paints the static frame (the + * Recharts in `renderToStaticMarkup` only paints the static frame (the * `` mounts but doesn't measure), so we assert on * `data-slot="drug-level-chart-area"` presence + the gradient * definition + the y-axis having no `` tick labels. @@ -25,8 +26,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; // Capture queryKey routing so different tests can stub different -// query results. The component reads three queries: -// - ["research-mode"] +// query results. The component reads two queries: // - ["medications", id, "glp1-details"] // - ["medications", id, "intake", "drug-level-chart"] const queryResults: Record = {}; @@ -109,15 +109,8 @@ beforeEach(() => { pkCalls.length = 0; }); -describe(" — gating decision tree", () => { +describe(" — default-on decision tree", () => { it("renders the unknown-drug placeholder for a non-GLP-1 brand", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T00:00:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", - }); - const html = render(); expect(html).toContain('data-slot="drug-level-chart-unknown-drug"'); @@ -128,48 +121,46 @@ describe(" — gating decision tree", () => { expect(pkCalls).toHaveLength(0); }); - it("renders the gated placeholder when Research Mode is OFF", () => { - setQueryResult("research-mode", { - enabled: false, - acknowledgedAt: null, - acknowledgedVersion: null, - currentDisclaimerVersion: "2026-05-14.1", + it("renders the chart for a GLP-1 med WITHOUT any Research Mode opt-in", () => { + // No research-mode query is stubbed at all — the chart must still + // render the curve for a recognised GLP-1 brand. + setQueryResult("medications/med-1/glp1-details", { + doseChanges: [ + { + id: "dc-1", + effectiveFrom: "2026-04-01T00:00:00Z", + doseValue: 7.5, + doseUnit: "mg", + }, + ], + }); + setQueryResult("medications/med-1/intake/drug-level-chart", { + events: [ + { + id: "ev-1", + takenAt: "2026-05-13T08:00:00Z", + skipped: false, + scheduledFor: "2026-05-13T08:00:00Z", + }, + ], }); - const html = render(); + const html = render( + , + ); - expect(html).toContain('data-slot="drug-level-chart-gated"'); - expect(html).toContain('data-stale="false"'); - expect(html).toContain("Research Mode is off"); - expect(html).toContain("Open Research Mode"); - expect(html).not.toContain('data-slot="drug-level-chart-area"'); - expect(pkCalls).toHaveLength(0); + expect(html).toContain('data-slot="drug-level-chart-area"'); + // The gated placeholder must never appear now. + expect(html).not.toContain('data-slot="drug-level-chart-gated"'); + // The estimate disclaimer is always attached. + expect(html).toContain('data-slot="drug-level-chart-disclaimer"'); + expect(pkCalls).toHaveLength(1); }); - it("renders the stale-disclaimer placeholder when versions differ", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-04-01T00:00:00Z", - acknowledgedVersion: "2026-04-01.1", - currentDisclaimerVersion: "2026-05-14.1", - }); - - const html = render(); - - expect(html).toContain('data-slot="drug-level-chart-gated"'); - expect(html).toContain('data-stale="true"'); - expect(html).toContain("re-acknowledgment"); - expect(html).not.toContain('data-slot="drug-level-chart-area"'); - expect(pkCalls).toHaveLength(0); - }); - - it("renders the empty state when gate is open but no intake events exist", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T00:00:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", - }); + it("renders the empty state when no intake events exist", () => { setQueryResult("medications/med-1/glp1-details", { doseChanges: [] }); setQueryResult("medications/med-1/intake/drug-level-chart", { events: [], @@ -180,16 +171,41 @@ describe(" — gating decision tree", () => { expect(html).toContain('data-slot="drug-level-chart-empty"'); expect(html).toContain("No intake events yet"); expect(html).not.toContain('data-slot="drug-level-chart-area"'); + // The estimate disclaimer stays attached even without data. + expect(html).toContain('data-slot="drug-level-chart-disclaimer"'); expect(pkCalls).toHaveLength(0); }); - it("renders the chart when gate is open AND intake events exist", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T00:00:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", + it("falls back to the headline dose when no dose-change rows exist", () => { + // No doseChanges rows — every intake resolves to the headline + // "7.5 mg" dose so the curve still renders for a typical med. + setQueryResult("medications/med-1/glp1-details", { doseChanges: [] }); + setQueryResult("medications/med-1/intake/drug-level-chart", { + events: [ + { + id: "ev-1", + takenAt: "2026-05-13T08:00:00Z", + skipped: false, + scheduledFor: "2026-05-13T08:00:00Z", + }, + ], }); + + const html = render( + , + ); + + expect(html).toContain('data-slot="drug-level-chart-area"'); + expect(pkCalls).toHaveLength(1); + const [call] = pkCalls; + expect(call.doses).toHaveLength(1); + expect(call.doses.every((d) => d.doseMg === 7.5)).toBe(true); + }); + + it("renders the chart when intake events exist", () => { setQueryResult("medications/med-1/glp1-details", { doseChanges: [ { @@ -245,18 +261,11 @@ describe(" — gating decision tree", () => { }); it("wraps the chart inside the canonical MedicationDetailSection chrome (UI-H1)", () => { - // v1.4.28 — the dashboard tile retired and the standalone - // mount on `/medications/[id]/history` lifts onto the shared - // `` chrome alongside Titration / + // The standalone mount on `/medications/[id]/history` lifts onto the + // shared `` chrome alongside Titration / // Scheduling / SideEffects. The aria-labelledby thread + the // `border-border/60 rounded-md border` shell are the // load-bearing seams. - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T00:00:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", - }); const html = render(); expect(html).toContain('data-slot="drug-level-chart"'); expect(html).toContain('aria-labelledby="drug-level-chart-title"'); @@ -268,12 +277,6 @@ describe(" — gating decision tree", () => { }); it("hides the y-axis tick labels (research §2.3, unit-less)", () => { - setQueryResult("research-mode", { - enabled: true, - acknowledgedAt: "2026-05-14T00:00:00Z", - acknowledgedVersion: "2026-05-14.1", - currentDisclaimerVersion: "2026-05-14.1", - }); setQueryResult("medications/med-1/glp1-details", { doseChanges: [] }); setQueryResult("medications/med-1/intake/drug-level-chart", { events: [ From 9652047e8e4f92647934bd7f005f653d9349f9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:58:26 +0200 Subject: [PATCH 05/11] fix(medications): order the drug-level chart above the dose-strength curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the GLP-1 drug-level estimate and the dose-strength (titration) curve out of the default-closed disclosure on both the medication detail and history pages so they are visible without a click. Render the drug-level estimate first — it is the curve the medication concerns — with the titration curve below it. --- src/app/medications/[id]/history/page.tsx | 39 +++++++++-------------- src/app/medications/[id]/page.tsx | 38 +++++++++------------- 2 files changed, 30 insertions(+), 47 deletions(-) diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index e33edcab7..d934ad2e2 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -129,31 +129,22 @@ export default function IntakeHistoryPage({ /> )} - {/* O-2 — dose-strength (titration) curve + estimated - active-ingredient curve as a default-CLOSED disclosure for - GLP-1 medications. Genuine context, but opt-in so the intake - table stays the dominant surface. The dose-strength curve plots - the user's own logged dose-change history; the drug-level chart - gates further on Research Mode. */} + {/* Estimated drug-level curve (the modelled active-ingredient + level) + dose-strength (titration) curve for GLP-1 medications. + Visible by default — the drug-level estimate is the one the + medication concerns, so it leads; the dose-strength curve plots + the user's own logged dose-change history below it. */} {medication && isGlp1 && ( -
- - {t("medications.detail.history.drugLevelDisclosure")} - -
- - -
-
+
+ + +
)} {importOpen && ( diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 32de81d9e..12c41cc55 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -235,30 +235,22 @@ export default function MedicationDetailPage({ (genuine history context). */} {isGlp1 && } - {/* GLP-1 dose-strength (titration) curve + estimated drug-level - chart as a default-CLOSED disclosure — genuine history context, - opt-in so the intake table stays the dominant surface. The - dose-strength curve plots the user's own logged dose-change - history; the drug-level chart gates further on Research Mode. */} + {/* GLP-1 estimated drug-level curve (the modelled active-ingredient + level) + dose-strength (titration) curve. Visible by default for + GLP-1 medications — the drug-level estimate is the one the + medication concerns, so it leads; the dose-strength curve plots + the user's own logged dose-change history below it. */} {isGlp1 && ( -
- - {t("medications.detail.history.drugLevelDisclosure")} - -
- - -
-
+
+ + +
)} ); From be90c4b9add0c0c420283126444e451d17a0fc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:57:11 +0200 Subject: [PATCH 06/11] fix(dashboard): never truncate the tile value, let the unit yield instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The small KPI tiles in the dashboard strip clipped the numeric value at narrow widths: the headline value span carried `min-w-0 truncate` while the unit span was `shrink-0`, so on a dense strip (many tiles → narrow grid column) the number — not the unit — shrank below its content width and ellipsis-clipped, rendering "130 mmHg" as a truncated "13…". Flip the priority. The value span is now `shrink-0 whitespace-nowrap` so every digit always renders in full on one line, and the unit span takes `min-w-0 truncate` so it yields when the column is too narrow. The paired-value font step-down (text-3xl → text-2xl for BP systolic/ diastolic) stays as headroom. Add guards asserting the value node never carries truncate/min-w-0 and the full reading (single and paired) renders inside it. --- .../charts/__tests__/trend-card.test.tsx | 87 +++++++++++++++++++ src/components/charts/trend-card.tsx | 30 ++++--- 2 files changed, 107 insertions(+), 10 deletions(-) diff --git a/src/components/charts/__tests__/trend-card.test.tsx b/src/components/charts/__tests__/trend-card.test.tsx index a16977ba6..926480a78 100644 --- a/src/components/charts/__tests__/trend-card.test.tsx +++ b/src/components/charts/__tests__/trend-card.test.tsx @@ -123,6 +123,93 @@ describe(" directionSentiment", () => { }); }); +describe(" value is never truncated", () => { + // v1.8.7 — the maintainer is emphatic: the numeric value must always + // render in full at any tile width (dense strip → narrowest column). + // The value span keeps its full intrinsic width (`shrink-0 + // whitespace-nowrap`) and never carries `truncate`/`min-w-0`; when + // space is tight the UNIT yields instead. These guards pin the + // contract so a future layout churn can't reintroduce the clip. + function valueSpan(html: string): string { + const marker = 'data-slot="trend-card-value"'; + const idx = html.indexOf(marker); + expect(idx).toBeGreaterThan(-1); + // Walk back to the opening "", idx); + return html.slice(open, close); + } + + it("renders the full BP value (130) — never clipped to '13'", () => { + const html = render( + , + ); + // Value span renders the full reading (formatter adds one decimal). + expect(valueSpan(html)).toContain("130.0"); + }); + + it("renders the full paired BP value (131/85)", () => { + const html = render( + , + ); + expect(valueSpan(html)).toContain("131.0/85.0"); + }); + + it("does not put truncate or min-w-0 on the value node", () => { + const html = render( + , + ); + const value = valueSpan(html); + expect(value).not.toContain("truncate"); + expect(value).not.toContain("min-w-0"); + // The value holds its width and the number stays on one line. + expect(value).toContain("shrink-0"); + expect(value).toContain("whitespace-nowrap"); + }); + + it("lets the unit yield (min-w-0 truncate) instead of the value", () => { + const html = render( + , + ); + // The unit text renders in full in the markup; the truncate is a + // density safeguard that only engages when the column is too narrow. + expect(html).toContain("mL/(kg·min)"); + }); +}); + describe(" responsive layout", () => { it("keeps long BP target tile content wrappable inside the card", () => { // v1.4.28 FB-C2 — the BD-Zielbereich tile dropped `avgAllTime` diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 84186ff47..008640077 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -265,21 +265,30 @@ export function TrendCard({ ask: "rechts neben der Zahl … der Pfeil [ist], wie gerade der Trend ist". */}
{latest !== null ? renderPair(latest, secondary?.latest) : "—"} - + {unit} Date: Tue, 2 Jun 2026 10:00:02 +0200 Subject: [PATCH 07/11] fix(insights): style the fallback trend caption like the advisor caption Extract the advisor caption's bordered card + icon + foreground prose shell into a shared TrendCaptionCard and render the additive-metric fallback caption through it, so the Trends-row captions read with one typography instead of the plain muted line standing out below the chart. The two paths now share a single presentational shell and can't drift apart. --- src/components/insights/trend-annotation.tsx | 93 +++++++++++++------- src/components/insights/trends-row.tsx | 18 ++-- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 3ebdc243a..009528e0a 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -43,6 +43,55 @@ export type TrendAnnotationConfidenceBand = "low" | "moderate" | "high"; export type TrendAnnotationStatus = "pending" | "needs_data" | "generated"; +/** + * Shared presentational shell for a filled trend caption — the bordered + * card + Sparkles affordance + `text-foreground` prose treatment. Both + * the advisor-authored annotation (the legacy triple) and the additive + * metric's standard description (`captionKey`) render through this shell + * so the Trends row reads with a single typographic rhythm and the two + * paths can't drift apart. `children` carries the line(s) below the + * caption prose (e.g. a confidence chip). `slot` / `metric` pass through + * to the wrapper's `data-*` hooks so each call site keeps its own + * test-stable selector. + */ +export function TrendCaptionCard({ + text, + slot, + metric, + children, +}: { + text: string; + slot: string; + metric: string; + children?: React.ReactNode; +}) { + return ( +
+
+ ); +} + interface TrendAnnotationProps { /** The metric this annotation describes. Drives the empty-state copy. */ metric: "bp" | "weight" | "mood"; @@ -122,36 +171,20 @@ export function TrendAnnotation({ } return ( -
-
+ {confidence ? ( + + {t(CONFIDENCE_LABEL_KEY[confidence])} + + ) : null} + ); } diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 77bc0e29d..1bfc94499 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -13,6 +13,7 @@ import { } from "@/lib/insights/trend-chart-select"; import { TrendAnnotation, + TrendCaptionCard, type TrendAnnotationConfidenceBand, type TrendAnnotationStatus, } from "./trend-annotation"; @@ -216,13 +217,16 @@ export function TrendsRow({ // v1.8.6 W8 — additive metrics carry no advisor // annotation. Paint the metric's standard one-line // description so the card is never caption-less. -

- {t(config.captionKey)} -

+ // v1.8.7 W-E — render that description through the same + // `` shell the advisor caption uses, so + // the fallback caption shares the bordered card, Sparkles + // affordance, and `text-foreground` typography rather than + // standing out as a plain muted line. + )}
); From aeb72d1a08d68f654e3e5d8d69431020c5fb8382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 10:09:53 +0200 Subject: [PATCH 08/11] fix(insights): regenerate only the user's locale on a measurement invalidation The write-path regenerate-on-invalidate enqueued a fresh assessment for both de and en on every dirtying measurement, while the read-path only ever serves the user's resolved locale. A frequent Apple-Health sync thus fanned out two provider calls per scope, half for a language the user never opens. Resolve the user's locale the way the read-path does (normalizeLocale of user.locale) and enqueue that single locale. The nightly warm pass still covers both locales for the rare locale switch. --- .../invalidate-status-insights.test.ts | 23 +++++++++++++++---- src/lib/insights/comprehensive-generate.ts | 18 +++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 5412fcfec..9fe03980b 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -12,10 +12,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const deleteMany = vi.fn(); const enqueueStatusGeneration = vi.fn(); +const userFindUnique = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) }, + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, }, })); @@ -29,6 +31,7 @@ beforeEach(() => { vi.clearAllMocks(); deleteMany.mockResolvedValue({ count: 0 }); enqueueStatusGeneration.mockResolvedValue(undefined); + userFindUnique.mockResolvedValue({ locale: "de" }); }); /** Distinct scopes the invalidator enqueued a regenerate for. */ @@ -100,17 +103,29 @@ describe("invalidateStatusInsightsForTypes", () => { expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); - it("enqueues a debounced regenerate for every dirtied scope, both locales", async () => { + it("enqueues a debounced regenerate for every dirtied scope, the user's locale only", async () => { + userFindUnique.mockResolvedValue({ locale: "de" }); await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); - // weight + bmi + general, each warmed for de + en. + // weight + bmi + general, each warmed once for the user's resolved locale. expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); - expect(enqueueStatusGeneration).toHaveBeenCalledTimes(6); + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(3); const locales = new Set( enqueueStatusGeneration.mock.calls.map( (c) => (c[0] as { locale: string }).locale, ), ); - expect([...locales].sort()).toEqual(["de", "en"]); + expect([...locales]).toEqual(["de"]); + }); + + it("resolves the user's locale (en) for the regenerate, never the unused one", async () => { + userFindUnique.mockResolvedValue({ locale: "en" }); + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + const locales = new Set( + enqueueStatusGeneration.mock.calls.map( + (c) => (c[0] as { locale: string }).locale, + ), + ); + expect([...locales]).toEqual(["en"]); }); it("does not blanket-evict general for an unrelated synced type", async () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index ba175209d..dd5203b33 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -48,6 +48,7 @@ import { } from "@/lib/ai/provider-runner"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; +import { normalizeLocale } from "@/lib/insights/status-shared"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -240,13 +241,22 @@ export async function invalidateStatusInsightsForTypes( // the cache is re-warmed in the background. The enqueue is coalesced per // `(user, metric, locale)` via the queue's `singletonKey` (120 s window), // so the constant Apple-Health sync cannot fan out a storm of jobs — a - // burst of samples collapses into one regenerate per scope per locale. + // burst of samples collapses into one regenerate per scope. // The stale-while-revalidate read keeps the previous assessment visible // until the fresh one lands, so the user never waits on a card. + // + // v1.8.7 — regenerate only the user's resolved locale, matching the + // read-path (every `*-status` GET serves `normalizeLocale(user.locale)`). + // Warming both locales doubled provider spend on every sync, half of it + // for a language the user never opens. The nightly warm pass still covers + // both locales for the rare locale switch. + const localeRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const locale = normalizeLocale(localeRow?.locale); for (const scope of scopes) { - for (const locale of ["de", "en"] as const) { - void enqueueStatusGeneration({ userId, metric: scope, locale }); - } + void enqueueStatusGeneration({ userId, metric: scope, locale }); } } From ecf4f06870e770fa709d2f91a3724fd669ff6920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 10:09:58 +0200 Subject: [PATCH 09/11] fix(dashboard): clip the tile card bound on a pathological value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline value span is shrink-0 whitespace-nowrap so it never truncates a digit — correct. A worst-case very wide value could still push the card's content past its grid column and break the layout. Add overflow-hidden to the tile card wrapper so a pathological value clips at the card bound rather than the grid, without re-introducing truncation of the number for realistic readings. --- src/components/charts/trend-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index 008640077..cfc73b02a 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -238,7 +238,7 @@ export function TrendCard({ }; return ( -
+
{/* v1.4.25 W20a — single-line discipline + deterministic height for the heading row so the value row below sits at the same baseline across every tile. The label is locale-abbreviated upstream (see From 04615d1bc7fc4649ba46cc12b8e49a5702c810f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 10:10:02 +0200 Subject: [PATCH 10/11] fix(medications): make the drug-level estimate disclaimer clearer The pharmacokinetic-estimate disclaimer rendered as a low-contrast muted italic footnote, easy to miss. The curve is a modelled value, never a measured blood concentration, and that framing must read clearly. Lift it to a bordered note with stronger text contrast and weight, keeping the wording unchanged and the tone informative rather than alarming. --- src/components/medications/DrugLevelChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/medications/DrugLevelChart.tsx b/src/components/medications/DrugLevelChart.tsx index 657ff7a29..bc3d26c59 100644 --- a/src/components/medications/DrugLevelChart.tsx +++ b/src/components/medications/DrugLevelChart.tsx @@ -231,7 +231,7 @@ export function DrugLevelChart({ medication, asOf }: DrugLevelChartProps) { modelled value, never a measured blood concentration. */} {!!drugId && (

{t("medications.researchMode.chart.estimateNote")} From d5f75d2e0336c5e0cccade4a5d9a0b55446e0d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 10:11:26 +0200 Subject: [PATCH 11/11] =?UTF-8?q?chore(release):=20v1.8.7=20=E2=80=94=20in?= =?UTF-8?q?stant=20insight=20assessments,=20GLP-1=20blood-level=20chart,?= =?UTF-8?q?=20tile=20+=20caption=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a090d8be4..9592bf143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.8.7] — 2026-06-02 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish + +### Fixed + +- **Insight assessments are instant again.** A category's assessment was being re-prepared on almost every visit: the constant Apple Health / Withings sync kept evicting the cached text, and a miss only ever deleted-and-waited. Assessments now serve the last good text immediately (stale-while-revalidate) and refresh in the background — "preparing" shows only when an assessment has genuinely never been generated. The background refresh is also debounced per metric, so a sync burst no longer triggers a storm of regenerations, and it warms only the active language (halving the work). +- **The dashboard tiles never truncate the value.** In a dense tile strip the number could clip or wrap mid-value (e.g. "130 mmHg" showing as "13"); the value now always renders in full and the unit yields the space instead. +- **GLP-1 medications show the drug-level-in-blood estimate by default.** The estimated concentration curve was hidden behind the Research-Mode opt-in and below the dose-strength curve; for GLP-1 medications it now shows by default, above the dose-strength curve, with the "educational estimate, not a measurement" disclaimer kept clearly attached. +- **The Trends captions read consistently.** Metrics without an advisor note previously rendered their caption in a different, plainer style that broke the row; every Trends caption now uses the same treatment. + +### Changed + +- **The mood assessment sits directly under the first chart** on the mood insights page. + ## [1.8.6] — 2026-06-01 — Compliance two-row return, insights polish, targets deprecation ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 15b29b6ee..cd0f537f7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.8.6 + version: 1.8.7 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index cee07cdbd..a9f9048de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.8.6", + "version": "1.8.7", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",