From 7e48e2c26d36eed9f10acbf2e8cc0524e82772ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:37:25 +0200 Subject: [PATCH 01/32] fix(insights): stop the daily briefing going blank on slow generations The comprehensive insight that feeds both the daily briefing and the insights trend narrative is a single reasoning-heavy generation over the full feature snapshot. On a large account it runs past the provider client's fixed 60 s timeout and is aborted mid-stream, so the chain reports an all-failed outcome, no cached block is written, and the surface falls back to its static placeholder on every visit. The warm paths bounded the same generation tighter still (45 s) and counted the failure with no observable signal, so the blank state was permanent and silent. Make the per-request upstream timeout configurable (CompletionParams.timeoutMs, default 60 s for every existing caller) and raise it to 120 s for the comprehensive surface only, across all three generation paths: the on-demand regenerate, the on-demand warm, and the nightly pre-generation. Raise the warm-pass budget to 130 s so the provider's own timeout governs a genuine completion rather than clipping it early. Annotate the comprehensive failure in both the generator and the nightly loop so a generation that keeps failing is queryable instead of leaving the surface empty without a trace. --- src/app/api/insights/generate/route.ts | 7 + src/lib/ai/__tests__/codex-timeout.test.ts | 76 +++++++++ src/lib/ai/ai-budgets.ts | 20 ++- src/lib/ai/anthropic-client.ts | 7 +- src/lib/ai/codex-client.ts | 4 +- src/lib/ai/local-client.ts | 3 +- src/lib/ai/openai-client.ts | 7 +- src/lib/ai/types.ts | 15 ++ .../comprehensive-warm-timeout.test.ts | 148 ++++++++++++++++++ src/lib/insights/comprehensive-generate.ts | 38 ++++- .../__tests__/insight-pregenerate.test.ts | 18 ++- src/lib/jobs/insight-pregenerate.ts | 25 ++- 12 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 src/lib/ai/__tests__/codex-timeout.test.ts create mode 100644 src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 731779898..849e7ad85 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -57,6 +57,7 @@ import { singleUserTurn, type InsightResult, } from "@/lib/ai/types"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { AllProvidersFailedError, @@ -700,6 +701,10 @@ export const POST = apiHandler((request: NextRequest) => user: userPrompt, temperature: 0.3, maxTokens: 1500, + // v1.21.5 — wider upstream budget so the reasoning-heavy briefing + // generation is not aborted at the client's 60 s default on large + // accounts (which returned an empty briefing + trend narrative). + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, // The reply is parsed with `JSON.parse` below, so opt the OpenAI / // Codex chains into their strict JSON mode (gated on this flag). responseFormat: "json", @@ -836,6 +841,8 @@ export const POST = apiHandler((request: NextRequest) => user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, temperature: 0.3, maxTokens: 1500, + // v1.21.5 — wider upstream budget; see the first generation call. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, // Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode. responseFormat: "json", }), diff --git a/src/lib/ai/__tests__/codex-timeout.test.ts b/src/lib/ai/__tests__/codex-timeout.test.ts new file mode 100644 index 000000000..b0c68750b --- /dev/null +++ b/src/lib/ai/__tests__/codex-timeout.test.ts @@ -0,0 +1,76 @@ +/** + * v1.21.5 — the Codex client honours the caller's per-request timeout + * override (`CompletionParams.timeoutMs`), falling back to the shared 60 s + * default when unset. The comprehensive briefing is the one caller that + * overrides it; the 60 s default was aborting its reasoning-heavy generation + * mid-stream on large accounts, leaving the briefing blank. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const safeFetch = vi.fn(); +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...a: unknown[]) => safeFetch(...a), +})); + +import { CodexClient } from "../codex-client"; +import { singleUserTurn } from "../types"; + +function sseResponse(): Response { + const encoder = new TextEncoder(); + const events = [ + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: '{"summary":"ok"}' }], + }, + })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { id: "r1", usage: { total_tokens: 10 } }, + })}\n\n`, + ]; + const stream = new ReadableStream({ + start(controller) { + for (const ev of events) controller.enqueue(encoder.encode(ev)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function makeClient() { + return new CodexClient({ + accessToken: "t", + accountId: "acct", + onTokenRefresh: vi + .fn() + .mockResolvedValue({ accessToken: "t", accountId: "acct" }), + slugChain: ["gpt-5.5"], + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + safeFetch.mockResolvedValue(sseResponse()); +}); + +describe("CodexClient timeout override", () => { + it("forwards an explicit timeoutMs to safeFetch", async () => { + await makeClient().generateCompletion( + singleUserTurn({ system: "s", user: "u", timeoutMs: 120_000 }), + ); + expect(safeFetch.mock.calls[0][2].timeoutMs).toBe(120_000); + }); + + it("falls back to the 60 s default when no override is set", async () => { + await makeClient().generateCompletion( + singleUserTurn({ system: "s", user: "u" }), + ); + expect(safeFetch.mock.calls[0][2].timeoutMs).toBe(60_000); + }); +}); diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index c1b4538fe..6716ac5ee 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -24,6 +24,15 @@ export interface AiBudget { * Optional for surfaces whose caller pins its own token const (self-context). */ maxTokens?: number; + /** + * v1.21.5 — optional per-surface upstream timeout (ms) threaded onto the + * provider call via `CompletionParams.timeoutMs`. Omitted → the client's + * shared 60 s default holds. Only the comprehensive briefing sets it: its + * reasoning-heavy generation over the full feature snapshot legitimately + * runs past 60 s on large accounts, and the default abort clipped it + * mid-stream — leaving the briefing permanently blank. + */ + timeoutMs?: number; } /** @@ -43,8 +52,17 @@ export const AI_BUDGETS = { * Comprehensive insight + dailyBriefing — the largest structured payload * (summary + recommendations + dailyBriefing 80-200 words + signals + * keyFindings + trendAnnotations + storyboardAnnotations). 1500 tokens. + * + * v1.21.5 — `timeoutMs` raised to 120 s for THIS surface only. The 60 s + * client default aborted the reasoning-heavy single-turn generation + * mid-stream on large accounts (observed: a 60.7 s "operation aborted due + * to timeout" hop, no upstream status), which left the daily briefing and + * the insights trend narrative that share its cached block blank. The + * generation runs off the request hot path (nightly warm + the on-demand + * background warm), and the explicit regenerate is rate-limited, so the + * wider ceiling is bounded. */ - comprehensive: { temperature: 0.3, maxTokens: 1500 }, + comprehensive: { temperature: 0.3, maxTokens: 1500, timeoutMs: 120_000 }, /** * Per-metric status assessment cards — output is a single diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 349db83d2..f2bea274d 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -261,7 +261,12 @@ export class AnthropicClient implements AIProvider { // check so a private/metadata address is rejected (SSRF/rebinding). // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. - { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, + // v1.21.5 — honour the caller's per-request timeout override; default 60 s. + { + timeoutMs: params.timeoutMs ?? 60_000, + requirePublicHost: true, + signal: params.signal, + }, ); if (!res.ok) { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 42b31c3c6..9a2232e2b 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -509,7 +509,9 @@ export class CodexClient implements AIProvider { }, // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. - { timeoutMs: 60_000, signal: params.signal }, + // v1.21.5 — honour the caller's per-request timeout override (the + // comprehensive briefing needs >60 s); default unchanged at 60 s. + { timeoutMs: params.timeoutMs ?? 60_000, signal: params.signal }, ); } diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index eaf30ceff..784e53337 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -128,7 +128,8 @@ export class LocalOpenAICompatibleClient implements AIProvider { // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. { - timeoutMs: 60_000, + // v1.21.5 — honour the caller's per-request timeout override; default 60 s. + timeoutMs: params.timeoutMs ?? 60_000, requirePublicHost: !allowPrivate, signal: params.signal, }, diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index 26b291296..04355ab25 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -92,7 +92,12 @@ export class OpenAIClient implements AIProvider { // address is rejected, closing the SSRF/rebinding surface. // v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so // a mid-generation abort tears the upstream call down early. - { timeoutMs: 60_000, requirePublicHost: true, signal: params.signal }, + // v1.21.5 — honour the caller's per-request timeout override; default 60 s. + { + timeoutMs: params.timeoutMs ?? 60_000, + requirePublicHost: true, + signal: params.signal, + }, ); if (!res.ok) { diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 318becc18..0837fbd01 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -280,6 +280,18 @@ export interface CompletionParams { * `safeFetch`. Omitted → behaviour is unchanged (timeout-only). */ signal?: AbortSignal; + /** + * v1.21.5 — per-request upstream timeout, in milliseconds. Each client + * passes this straight to its `safeFetch` call, falling back to the shared + * 60 s default when unset — so every existing caller (Coach, status cards) + * is byte-identical. The comprehensive briefing surface is the one caller + * that overrides it: its reasoning-heavy single-turn generation over the + * full feature snapshot ran past the 60 s default on large accounts and was + * aborted mid-stream ("operation aborted due to timeout"), which left the + * briefing — and the insights trend narrative that reads the same cached + * block — permanently blank. Sized in `AI_BUDGETS.comprehensive.timeoutMs`. + */ + timeoutMs?: number; } export interface CompletionResult { @@ -324,6 +336,8 @@ export function singleUserTurn(p: { toolChoice?: "auto" | "none"; /** v1.20.1 — caller-owned cancel signal threaded to the client fetch. */ signal?: AbortSignal; + /** v1.21.5 — per-request upstream timeout override (ms); see CompletionParams. */ + timeoutMs?: number; }): CompletionParams { const images = p.images ?? []; const documents = p.documents ?? []; @@ -361,6 +375,7 @@ export function singleUserTurn(p: { tools: p.tools, toolChoice: p.toolChoice, signal: p.signal, + timeoutMs: p.timeoutMs, }; } diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts new file mode 100644 index 000000000..e90976081 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -0,0 +1,148 @@ +/** + * v1.21.5 — comprehensive briefing warm-path robustness. + * + * Two guarantees, both regressions that left the daily briefing (and the + * insights trend narrative that reads the same cached block) permanently + * blank for a large account on a reasoning provider: + * + * 1. The generation call carries the wider per-surface upstream timeout + * (`AI_BUDGETS.comprehensive.timeoutMs`), so a reasoning-heavy single + * turn is no longer aborted at the client's 60 s default mid-stream. + * 2. A provider failure does NOT silently persist an empty block: no cache + * row is written, and the failure is surfaced as a queryable wide-event + * annotation rather than swallowed. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); +const annotate = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => { + // Defined inside the factory: the mock is hoisted above the module body, so + // a top-level class reference would hit the temporal dead zone. + class AllProvidersFailedError extends Error { + constructor() { + super("All providers failed"); + this.name = "AllProvidersFailedError"; + } + } + return { + AllProvidersFailedError, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), + }; +}); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: (...a: unknown[]) => annotate(...a), +})); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; +import { AllProvidersFailedError } from "@/lib/ai/provider-runner"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; + +const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } }; +const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } }); + +beforeEach(() => { + vi.clearAllMocks(); + resolveProviderChain.mockResolvedValue([ + { providerType: "codex", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + insightsBriefingRerollDate: null, + }); +}); + +describe("comprehensive warm-path timeout + failure observability", () => { + it("threads the wider comprehensive timeout onto the provider call", async () => { + // The budget must carry an override above the 60 s client default. + expect(AI_BUDGETS.comprehensive.timeoutMs).toBeGreaterThan(60_000); + + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: VALID, tokensUsed: 10, model: "m" }, + workingProvider: { providerType: "codex" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "generated", providerType: "codex" }); + const params = runRawCompletionWithFallback.mock.calls[0][0].params; + expect(params.timeoutMs).toBe(AI_BUDGETS.comprehensive.timeoutMs); + }); + + it("does not silently persist an empty block when every provider fails", async () => { + // The mocked class ignores the argument; `[]` satisfies the real type. + runRawCompletionWithFallback.mockRejectedValue( + new AllProvidersFailedError([]), + ); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ + status: "failed", + reason: "all-providers-failed", + }); + // No cache write — the briefing block is never persisted empty, so the + // next warm / visit retries instead of serving a permanent blank. + expect(userUpdate).not.toHaveBeenCalled(); + // The failure is queryable, not swallowed. + const failAnnotation = annotate.mock.calls.find( + (c) => + (c[0] as { action?: { name?: string } }).action?.name === + "insights.generate.comprehensive_failed", + ); + expect(failAnnotation).toBeDefined(); + expect( + (failAnnotation![0] as { meta?: { reason?: string } }).meta?.reason, + ).toBe("all-providers-failed"); + }); +}); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index ab052a27e..8c683c5d7 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -220,6 +220,9 @@ async function rerollBriefingParagraph(args: { // exactly what we are varying. Higher temperature for prose variety. temperature: BRIEFING_REROLL_TEMPERATURE, maxTokens: AI_BUDGETS.comprehensive.maxTokens, + // v1.21.5 — wider upstream budget so the reasoning-heavy briefing + // generation is not clipped at the client's 60 s default mid-stream. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, responseFormat: "json", }), }); @@ -957,6 +960,9 @@ export async function generateComprehensiveInsight( user: userPrompt, temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, + // v1.21.5 — wider upstream budget so the reasoning-heavy briefing + // generation is not clipped at the client's 60 s default mid-stream. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, // v1.18.7 — structured surface: opt the non-OpenAI chains into their // strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a // first-pass JSON miss is rarer; stripJsonFences stays the net. @@ -966,10 +972,30 @@ export async function generateComprehensiveInsight( result = fallback.result; workingProviderType = fallback.workingProvider.providerType; } catch (e) { - if (e instanceof AllProvidersFailedError) { - return { status: "failed", reason: "all-providers-failed" }; - } - return { status: "failed", reason: "provider-error" }; + // v1.21.5 — make the failed generation queryable. This path used to return + // a `failed` outcome with NO annotation, so a provider that consistently + // failed (e.g. a codex briefing aborted by the 60 s timeout on a large + // account) left the cached block empty AND emitted nothing the operator + // could grep — the briefing and the insights trend narrative that share + // the block stayed blank with no signal. No cache row is written, so the + // next warm / visit retries; the wider `timeoutMs` above is what lets that + // retry actually land. + const reason = + e instanceof AllProvidersFailedError + ? "all-providers-failed" + : "provider-error"; + const err = e as { httpStatus?: number; message?: string }; + annotate({ + action: { name: "insights.generate.comprehensive_failed" }, + meta: { + locale, + reason, + provider_status: + typeof err.httpStatus === "number" ? err.httpStatus : null, + provider_message: err.message?.slice(0, 240) ?? null, + }, + }); + return { status: "failed", reason }; } // Anthropic + local have no native JSON mode, so a ```json-fenced or @@ -999,6 +1025,8 @@ export async function generateComprehensiveInsight( )}`, temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, + // v1.21.5 — wider upstream budget; see the first generation call. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, responseFormat: "json", }), }); @@ -1038,6 +1066,8 @@ export async function generateComprehensiveInsight( user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, + // v1.21.5 — wider upstream budget; see the first generation call. + timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, responseFormat: "json", }), }); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index fd60e55a7..1dc34196b 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -296,7 +296,9 @@ describe("runInsightPregenerate — outcome tally", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - 120_000, + // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the + // bounded budget fires before this "slow" generation settles. + 200_000, ); }), ); @@ -312,7 +314,7 @@ describe("runInsightPregenerate — outcome tally", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(140_000); const result = await promise; // The timeout fired, aborted the still-running generation (so its @@ -787,7 +789,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { // advance past it so the warm continues. setTimeout( () => resolve({ status: "generated", providerType: "x" }), - 120_000, + // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the + // bounded budget fires before this "slow" generation settles. + 200_000, ); }), ); @@ -804,7 +808,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { warmGenericMetrics, }); // Advance past the comprehensive budget so withTimeout fires. - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(140_000); const result = await promise; for (const g of statusGenerators) { @@ -902,7 +906,9 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { observedSignal = opts.signal; setTimeout( () => resolve({ status: "generated", providerType: "x" }), - 120_000, + // v1.21.5 — above COMPREHENSIVE_WARM_TIMEOUT_MS (130 s) so the + // bounded budget fires before this "slow" generation settles. + 200_000, ); }), ); @@ -918,7 +924,7 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { statusGenerators, warmGenericMetrics, }); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(140_000); const result = await promise; // The timeout fired and aborted the still-running generation. diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index bb878ef4d..ed91ac1d3 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -156,8 +156,16 @@ const WARM_PASS_CONCURRENCY = 3; * loop the cap also bounds head-of-line blocking: without it one stalled * provider pinned the whole batch behind a single candidate. Set a hair * below the queue's own retry horizon so a single tick stays bounded. + * + * v1.21.5 — raised from 45 s. The old cap sat BELOW the provider call's own + * timeout (`AI_BUDGETS.comprehensive.timeoutMs`, now 120 s), so on a large + * account the warm bound — not the provider — aborted the generation, and the + * briefing never landed. The cap must sit just above the provider timeout so + * the provider's own abort governs a genuine completion while this stays the + * backstop against a wedged call. The per-user budget gate + the WARM_PASS + * concurrency cap still bound total nightly cost. */ -const COMPREHENSIVE_WARM_TIMEOUT_MS = 45_000; +const COMPREHENSIVE_WARM_TIMEOUT_MS = 130_000; /** Per-user result of one forced full warm. */ export interface ForceWarmResult { @@ -540,6 +548,21 @@ export async function runInsightPregenerate( ); if (bounded.timedOut || bounded.errored || bounded.value === null) { result.failed++; + // v1.21.5 — make the warm failure queryable. The nightly loop used to + // bump `failed` with no annotation, so a candidate whose comprehensive + // generation timed out (e.g. the briefing clipped at the old 45 s + // bound) left the cached block empty AND emitted nothing greppable. + annotate({ + action: { name: "insights.pregenerate.comprehensive_failed" }, + meta: { + locale, + cause: bounded.timedOut + ? "timeout" + : bounded.errored + ? "error" + : "null", + }, + }); } else { outcome = bounded.value; switch (outcome.status) { From d58116e3ffe7f1e44ca28e8a31066b6813cc014d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:15 +0200 Subject: [PATCH 02/32] fix(settings): pin the settings and admin sidebars on scroll The desktop section nav in both shells used a single sticky wrapper at top-20. Because the nav can run taller than the scroll viewport and the top offset assumed a topbar that sits outside the scroll region, the panel drifted with the content and re-pinned abruptly on scroll-up. Cap the wrapper height to the viewport and let it scroll independently (top-6, max-h-[calc(100dvh-5.5rem)], overflow-y-auto, no-scrollbar) so the nav stays pinned. Covers every /settings/* and /admin/* page through the shared shells. --- src/components/admin/admin-shell.tsx | 2 +- src/components/settings/settings-shell.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index f0bd0209c..39f94bb37 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -316,7 +316,7 @@ export function AdminShell({ active, children }: AdminShellProps) { aria-label={t("admin.shell.sectionsNav")} className="hidden md:col-start-1 md:row-start-2 md:block" > -
+
  • -
    +
      {visibleSections.map((section) => { const isActive = section.slug === activeSlug; From f4815fafe83cd6cca24f4e05c5863bdda553e7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:23 +0200 Subject: [PATCH 03/32] refactor(settings): drop the unused section heading frame SettingsSectionFrame re-rendered the page heading and subtitle that SettingsShell already paints from the section slug. No route used it; only the per-section smoke tests rendered bodies through it to assert the visible heading and its id. Remove the component and move the heading wrapper those tests rely on into a test-only harness that reproduces the shell's heading markup, so the smoke tests keep asserting what the user actually sees. --- .../settings/__tests__/coach-section.test.tsx | 2 +- .../__tests__/export-section.test.tsx | 2 +- .../gesundheitsakte-section.test.tsx | 2 +- .../__tests__/medications-section.test.tsx | 2 +- .../settings/__tests__/mood-section.test.tsx | 2 +- .../__tests__/section-frame-harness.tsx | 45 +++++++ .../settings/__tests__/sections.test.tsx | 2 +- .../settings/settings-section-frame.tsx | 113 ------------------ 8 files changed, 51 insertions(+), 119 deletions(-) create mode 100644 src/components/settings/__tests__/section-frame-harness.tsx delete mode 100644 src/components/settings/settings-section-frame.tsx diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 921dae86a..0a22bff56 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -31,7 +31,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { CoachSection } from "../coach-section"; function render(locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index bc648c7d2..fc42f9c5a 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -44,7 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { ExportSection } from "../export-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx index baee6ffba..a66fd760a 100644 --- a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx +++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { GesundheitsakteSection } from "../gesundheitsakte-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 7bc97a732..f44a9f36d 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -65,7 +65,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MedicationsSection } from "../medications-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 5f949344b..894b02cc4 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,7 +131,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import { MoodSection } from "../mood-section"; function render(locale: "en" | "de" = "en"): string { diff --git a/src/components/settings/__tests__/section-frame-harness.tsx b/src/components/settings/__tests__/section-frame-harness.tsx new file mode 100644 index 000000000..33e0fd68e --- /dev/null +++ b/src/components/settings/__tests__/section-frame-harness.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * Test-only harness that reproduces the page heading the live `SettingsShell` + * paints for a given section slug (`settings-shell.tsx` headingBlock). The + * per-section SSR smoke tests render a section body through this harness so + * they can assert the visible heading + its historic `settings-section--title` + * id alongside the body — mirroring what a user sees on the route. + * + * Production no longer wraps section bodies in a standalone frame component: + * the shell paints the heading from the slug and each section body is just its + * cards. This harness lives in `__tests__` only and is never bundled. + */ + +import * as React from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { SettingsSectionSlug } from "../section-slugs"; + +export function SettingsSectionFrame({ + slug, + children, +}: { + slug: SettingsSectionSlug; + children: React.ReactNode; +}) { + const { t } = useTranslations(); + const titleId = `settings-section-${slug}-title`; + + return ( +
      +
      +
      +

      + {t(`settings.sections.${slug}.title`)} +

      +

      + {t(`settings.sections.${slug}.subtitle`)} +

      +
      +
      + {children} +
      + ); +} diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 296ce813a..5744b4ef7 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -87,7 +87,7 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({ ThresholdsEditorSection: () =>
      , })); import { I18nProvider } from "@/lib/i18n/context"; -import { SettingsSectionFrame } from "../settings-section-frame"; +import { SettingsSectionFrame } from "./section-frame-harness"; import type { SettingsSectionSlug } from "../section-slugs"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; diff --git a/src/components/settings/settings-section-frame.tsx b/src/components/settings/settings-section-frame.tsx deleted file mode 100644 index 98b9eee63..000000000 --- a/src/components/settings/settings-section-frame.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -/** - * `` — the canonical page-frame for every Settings - * section. The settings twin of the admin renderer's `SectionFrame` - * (`src/app/admin/[section]/renderer.tsx`): it emits the one visible - * heading + subheading every page must show, in the reference style used by - * the Admin Konsole → Übersicht landing. - * - * Before this primitive every settings section hand-rolled its own - * `sr-only` `

      `, so no settings page showed a visible heading and the - * left-menu-to-first-card alignment drifted page to page. Routing all 19 - * sections through one frame fixes both: a constant-height heading block - * above the first card, and a real visible `h1 + subtitle`. - * - * - * {cards} - * - * - * Title comes from `settings.sections..title`; subtitle from - * `settings.sections..subtitle`. The `

      ` keeps the historic - * `settings-section--title` id so the section's own `aria-labelledby` - * linkage — and the spotlight-tour anchors that reference it — stay intact. - * - * `topSlot` renders ABOVE the heading (used by the Layout-hub child - * editors for their "← back to hub" link, which must sit above the title). - * `headingAccessory` renders inline at the top-right of the heading row - * (used by Gesundheitsakte for its guided-tour re-entry trigger). - */ - -import * as React from "react"; - -import { useTranslations } from "@/lib/i18n/context"; -import type { SettingsSectionSlug } from "./section-slugs"; - -interface SettingsSectionFrameBaseProps { - /** Optional content rendered above the heading (e.g. a back-to-hub link). */ - topSlot?: React.ReactNode; - /** Optional content rendered inline at the top-right of the heading row - * (e.g. a guided-tour re-entry trigger). */ - headingAccessory?: React.ReactNode; - children: React.ReactNode; -} - -/** - * Slug mode (the 19 shell sections): title + subtitle come from - * `settings.sections..{title,subtitle}` and the `

      ` keeps the - * historic `settings-section--title` id for `aria-labelledby` and - * spotlight-tour anchors. - */ -interface SettingsSectionFrameSlugProps extends SettingsSectionFrameBaseProps { - slug: SettingsSectionSlug; -} - -/** - * Explicit mode (the per-module settings pages — Vorsorge / Illness / Labs — - * which deliberately stay out of the slug registry because their static - * routes win over `/settings/[section]`): caller passes resolved strings and - * an optional heading id. This lets `ModuleSettingsFrame` delegate here so - * every settings page shares one heading frame. - */ -interface SettingsSectionFrameExplicitProps extends SettingsSectionFrameBaseProps { - title: string; - subtitle: string; - /** Heading element id (defaults to a stable slug-less id). */ - titleId?: string; -} - -export type SettingsSectionFrameProps = - | SettingsSectionFrameSlugProps - | SettingsSectionFrameExplicitProps; - -function isSlugProps( - props: SettingsSectionFrameProps, -): props is SettingsSectionFrameSlugProps { - return "slug" in props; -} - -export function SettingsSectionFrame(props: SettingsSectionFrameProps) { - const { t } = useTranslations(); - const { topSlot, headingAccessory, children } = props; - - let title: string; - let subtitle: string; - let titleId: string; - if (isSlugProps(props)) { - titleId = `settings-section-${props.slug}-title`; - title = t(`settings.sections.${props.slug}.title`); - subtitle = t(`settings.sections.${props.slug}.subtitle`); - } else { - titleId = props.titleId ?? "settings-section-title"; - title = props.title; - subtitle = props.subtitle; - } - - return ( -
      - {topSlot ?
      {topSlot}
      : null} -
      -
      -

      - {title} -

      -

      {subtitle}

      -
      - {headingAccessory ? ( -
      {headingAccessory}
      - ) : null} -
      - {children} -
      - ); -} From 774bc37382bdbd1f6f363018ab0cf74b74b8e917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:46:30 +0200 Subject: [PATCH 04/32] refactor(settings): route three stray cards through SettingsCard The Layout hub links, the global injection-site exclusion card, and the mobile passkey list hand-rolled their own card surfaces with rounded-lg or sm:p-5 padding, drifting from the SettingsCard contract (rounded-xl, p-4 md:p-6). Wrap them in SettingsCard so every settings surface paints the same radius, border, and padding. Content and behaviour are unchanged. --- .../account-section/passkey-list-section.tsx | 8 +++----- src/components/settings/injection-sites-card.tsx | 5 +++-- src/components/settings/layout-section.tsx | 15 +++++++-------- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx index 4144f1ef9..c28c5a274 100644 --- a/src/components/settings/account-section/passkey-list-section.tsx +++ b/src/components/settings/account-section/passkey-list-section.tsx @@ -15,6 +15,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { SettingsCard } from "@/components/settings/settings-card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { formatDate } from "@/lib/format"; @@ -198,10 +199,7 @@ export function PasskeyListSection({ data-testid="passkeys-mobile-list" > {passkeys.map((pk) => ( -
    • +

      {pk.name}

      @@ -267,7 +265,7 @@ export function PasskeyListSection({
      -
    • + ))}

    {deleteMsg && ( diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 3c1faf124..78cfb3a24 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Syringe } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Checkbox } from "@/components/ui/checkbox"; import { useTranslations } from "@/lib/i18n/context"; @@ -66,7 +67,7 @@ export function InjectionSitesCard({ } return ( -
    + ))}
    -
    + ); } diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index e5ad4528c..7605fcdc4 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -8,8 +8,8 @@ import { type LucideIcon, } from "lucide-react"; -import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCard } from "./settings-card"; /** * v1.17.1 (F-2) — the one "Layout & Personalization" home. @@ -53,19 +53,18 @@ const LAYOUT_LINKS: ReadonlyArray = [ export function LayoutSection() { const { t } = useTranslations(); - // v1.18.6 (W9) — the visible heading + subtitle now come from the shared - // `` in the route; this body is the hub link list. + // The visible heading + subtitle are painted by `SettingsShell` from the + // section slug; this body is the hub link list. return (
      {LAYOUT_LINKS.map((link) => { const Icon = link.icon; return (
    • -
    • ); })} From a9892e9586866e2e2f8f94d82baa75caa92a279d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:48:04 +0200 Subject: [PATCH 05/32] refactor(checkups): rename the preventive-care slug to /checkups Move the preventive-care surface from the German-derived slug /vorsorge to the English /checkups and make the feature read consistently across locales. The route directory, the nav entry, the dashboard card link, and the onboarding tour step now point at /checkups. A permanent redirect keeps old bookmarks, the PWA's cached navigation, and push deep-links resolving. Display label is now "Checkups" in English and the equivalent term in Spanish (Revisiones), French (Examens), Italian (Controlli), and Polish (Badania); German keeps "Vorsorge". The internal id stays "vorsorge" so saved layouts, module-order preferences, and tour state keep resolving without a data migration. --- messages/en.json | 24 +++++++++---------- messages/es.json | 24 +++++++++---------- messages/fr.json | 24 +++++++++---------- messages/it.json | 24 +++++++++---------- messages/pl.json | 24 +++++++++---------- next.config.ts | 15 ++++++++++++ src/app/{vorsorge => checkups}/page.tsx | 4 ++-- .../layout/__tests__/nav-model.test.ts | 18 +++++++------- .../layout/__tests__/sidebar-nav.test.tsx | 2 +- src/components/layout/nav-model.ts | 2 +- .../vorsorge-dashboard-card.tsx | 4 ++-- src/lib/onboarding/tour-state.ts | 2 +- 12 files changed, 91 insertions(+), 76 deletions(-) rename src/app/{vorsorge => checkups}/page.tsx (93%) diff --git a/messages/en.json b/messages/en.json index 4e8e9c6f5..f8cfcef9f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -238,7 +238,7 @@ }, "cycle": "Cycle", "labs": "Labs", - "vorsorge": "Preventive Care", + "vorsorge": "Checkups", "illness": "Illness" }, "auth": { @@ -3677,8 +3677,8 @@ "body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while." }, "vorsorge": { - "title": "Preventive care", - "body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report." + "title": "Checkups", + "body": "Checkups remind you of screenings and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report." }, "cycle": { "title": "Cycle", @@ -3702,7 +3702,7 @@ }, "export": { "title": "Doctor report", - "body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." + "body": "The doctor report bundles readings, medications, lab values, and checkups as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." }, "achievements": { "title": "Achievements", @@ -4132,8 +4132,8 @@ "subtitle": "Manage the view and order of episodes." }, "vorsorge": { - "title": "Preventive care", - "description": "View, order, and individual preventive-care reminders.", + "title": "Checkups", + "description": "View, order, and individual checkup reminders.", "subtitle": "Manage the view, order, and individual reminders." }, "modules": { @@ -6483,13 +6483,13 @@ "selectionBarLabel": "Selection actions" }, "measurementReminders": { - "sectionTitle": "Preventive care", + "sectionTitle": "Checkups", "sectionDescription": "Reminders for what to measure or check, when, and where.", "addButton": "Add reminder", "markDone": "Done", "delete": "Delete", "deleteConfirmTitle": "Delete reminder?", - "deleteConfirmDescription": "This preventive-care reminder will be permanently deleted.", + "deleteConfirmDescription": "This checkup reminder will be permanently deleted.", "disabledBadge": "Off", "nextDue": { "none": "Not scheduled", @@ -6551,7 +6551,7 @@ "rruleHint": "RFC-5545, e.g. FREQ=YEARLY for an annual check.", "firstDue": "First due (optional)" }, - "pushTitle": "Preventive-care reminder", + "pushTitle": "Checkup reminder", "pushBody": "Time for: {label}", "pushLocation": "(at {location})", "edit": "Edit", @@ -6562,7 +6562,7 @@ "measureNow": "Measure now", "nextDueLabel": "Next due:", "lastDoneLabel": "Last done:", - "customize": "Customize preventive care", + "customize": "Customize checkups", "adherence": { "label": "Within interval", "onTrack": "On track", @@ -6578,8 +6578,8 @@ "empty": "No reminders to customize yet." }, "dashboard": { - "empty": "No upcoming preventive-care reminders.", - "openLink": "Open preventive care" + "empty": "No upcoming checkup reminders.", + "openLink": "Open checkups" }, "showMeasurements": "Show measurements" }, diff --git a/messages/es.json b/messages/es.json index fe481268d..3a14f746a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -238,7 +238,7 @@ }, "cycle": "Ciclo", "labs": "Análisis", - "vorsorge": "Prevención", + "vorsorge": "Revisiones", "illness": "Enfermedad" }, "auth": { @@ -3677,8 +3677,8 @@ "body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo." }, "vorsorge": { - "title": "Prevención", - "body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico." + "title": "Revisiones", + "body": "Las revisiones te recuerdan controles y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico." }, "cycle": { "title": "Ciclo", @@ -3702,7 +3702,7 @@ }, "export": { "title": "Informe médico", - "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados." + "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y revisiones en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados." }, "achievements": { "title": "Logros", @@ -4132,8 +4132,8 @@ "subtitle": "Gestiona la vista y el orden de los episodios." }, "vorsorge": { - "title": "Prevención", - "description": "Vista, orden y recordatorios de prevención individuales.", + "title": "Revisiones", + "description": "Vista, orden y recordatorios de revisión individuales.", "subtitle": "Gestiona la vista, el orden y los recordatorios individuales." }, "modules": { @@ -6483,13 +6483,13 @@ "selectionBarLabel": "Acciones de selección" }, "measurementReminders": { - "sectionTitle": "Prevención", + "sectionTitle": "Revisiones", "sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.", "addButton": "Añadir recordatorio", "markDone": "Hecho", "delete": "Eliminar", "deleteConfirmTitle": "¿Eliminar recordatorio?", - "deleteConfirmDescription": "Este recordatorio de prevención se eliminará de forma permanente.", + "deleteConfirmDescription": "Este recordatorio de revisión se eliminará de forma permanente.", "disabledBadge": "Apagado", "nextDue": { "none": "Sin programar", @@ -6551,7 +6551,7 @@ "rruleHint": "RFC-5545, p. ej. FREQ=YEARLY para una revisión anual.", "firstDue": "Primer vencimiento (opcional)" }, - "pushTitle": "Recordatorio de prevención", + "pushTitle": "Recordatorio de revisión", "pushBody": "Toca: {label}", "pushLocation": "(en {location})", "edit": "Editar", @@ -6562,7 +6562,7 @@ "measureNow": "Medir ahora", "nextDueLabel": "Próximo vencimiento:", "lastDoneLabel": "Última vez:", - "customize": "Personalizar prevención", + "customize": "Personalizar revisiones", "adherence": { "label": "Dentro del intervalo", "onTrack": "Al día", @@ -6578,8 +6578,8 @@ "empty": "Aún no hay recordatorios para personalizar." }, "dashboard": { - "empty": "No hay recordatorios de prevención próximos.", - "openLink": "Abrir prevención" + "empty": "No hay revisiones próximas.", + "openLink": "Abrir revisiones" }, "showMeasurements": "Ver mediciones" }, diff --git a/messages/fr.json b/messages/fr.json index 11ab9eee5..fd8340c16 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -238,7 +238,7 @@ }, "cycle": "Cycle", "labs": "Analyses", - "vorsorge": "Prévention", + "vorsorge": "Examens", "illness": "Maladie" }, "auth": { @@ -3677,8 +3677,8 @@ "body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps." }, "vorsorge": { - "title": "Prévention", - "body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical." + "title": "Examens", + "body": "Les examens vous rappellent dépistages et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical." }, "cycle": { "title": "Cycle", @@ -3702,7 +3702,7 @@ }, "export": { "title": "Rapport médical", - "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." + "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et examens en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." }, "achievements": { "title": "Réussites", @@ -4132,8 +4132,8 @@ "subtitle": "Gérez l’affichage et l’ordre des épisodes." }, "vorsorge": { - "title": "Prévention", - "description": "Affichage, ordre et rappels de prévention individuels.", + "title": "Examens", + "description": "Affichage, ordre et rappels d’examen individuels.", "subtitle": "Gérez l’affichage, l’ordre et les rappels individuels." }, "modules": { @@ -6483,13 +6483,13 @@ "selectionBarLabel": "Actions de sélection" }, "measurementReminders": { - "sectionTitle": "Prévention", + "sectionTitle": "Examens", "sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.", "addButton": "Ajouter un rappel", "markDone": "Terminé", "delete": "Supprimer", "deleteConfirmTitle": "Supprimer le rappel ?", - "deleteConfirmDescription": "Ce rappel de prévention sera définitivement supprimé.", + "deleteConfirmDescription": "Ce rappel d’examen sera définitivement supprimé.", "disabledBadge": "Désactivé", "nextDue": { "none": "Non planifié", @@ -6551,7 +6551,7 @@ "rruleHint": "RFC-5545, p. ex. FREQ=YEARLY pour un contrôle annuel.", "firstDue": "Première échéance (facultatif)" }, - "pushTitle": "Rappel de prévention", + "pushTitle": "Rappel d’examen", "pushBody": "C'est l'heure : {label}", "pushLocation": "(chez {location})", "edit": "Modifier", @@ -6562,7 +6562,7 @@ "measureNow": "Mesurer maintenant", "nextDueLabel": "Prochaine échéance :", "lastDoneLabel": "Dernière fois :", - "customize": "Personnaliser la prévention", + "customize": "Personnaliser les examens", "adherence": { "label": "Dans l'intervalle", "onTrack": "Dans les temps", @@ -6578,8 +6578,8 @@ "empty": "Aucun rappel à personnaliser pour l'instant." }, "dashboard": { - "empty": "Aucun rappel de prévention à venir.", - "openLink": "Ouvrir la prévention" + "empty": "Aucun rappel d’examen à venir.", + "openLink": "Ouvrir les examens" }, "showMeasurements": "Afficher les mesures" }, diff --git a/messages/it.json b/messages/it.json index b3f1f0c92..8a81972dd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -238,7 +238,7 @@ }, "cycle": "Ciclo", "labs": "Esami", - "vorsorge": "Prevenzione", + "vorsorge": "Controlli", "illness": "Malattia" }, "auth": { @@ -3677,8 +3677,8 @@ "body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo." }, "vorsorge": { - "title": "Prevenzione", - "body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico." + "title": "Controlli", + "body": "I controlli ti ricordano screening e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico." }, "cycle": { "title": "Ciclo", @@ -3702,7 +3702,7 @@ }, "export": { "title": "Referto medico", - "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati." + "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e controlli in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati." }, "achievements": { "title": "Traguardi", @@ -4132,8 +4132,8 @@ "subtitle": "Gestisci la vista e l’ordine degli episodi." }, "vorsorge": { - "title": "Prevenzione", - "description": "Vista, ordine e singoli promemoria di prevenzione.", + "title": "Controlli", + "description": "Vista, ordine e singoli promemoria di controllo.", "subtitle": "Gestisci la vista, l’ordine e i singoli promemoria." }, "modules": { @@ -6483,13 +6483,13 @@ "selectionBarLabel": "Azioni di selezione" }, "measurementReminders": { - "sectionTitle": "Prevenzione", + "sectionTitle": "Controlli", "sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.", "addButton": "Aggiungi promemoria", "markDone": "Fatto", "delete": "Elimina", "deleteConfirmTitle": "Eliminare il promemoria?", - "deleteConfirmDescription": "Questo promemoria di prevenzione verrà eliminato definitivamente.", + "deleteConfirmDescription": "Questo promemoria di controllo verrà eliminato definitivamente.", "disabledBadge": "Disattivato", "nextDue": { "none": "Non pianificato", @@ -6551,7 +6551,7 @@ "rruleHint": "RFC-5545, es. FREQ=YEARLY per un controllo annuale.", "firstDue": "Prima scadenza (facoltativo)" }, - "pushTitle": "Promemoria di prevenzione", + "pushTitle": "Promemoria di controllo", "pushBody": "È ora di: {label}", "pushLocation": "(presso {location})", "edit": "Modifica", @@ -6562,7 +6562,7 @@ "measureNow": "Misura ora", "nextDueLabel": "Prossima scadenza:", "lastDoneLabel": "Ultima volta:", - "customize": "Personalizza prevenzione", + "customize": "Personalizza controlli", "adherence": { "label": "Nell'intervallo", "onTrack": "In linea", @@ -6578,8 +6578,8 @@ "empty": "Nessun promemoria da personalizzare." }, "dashboard": { - "empty": "Nessun promemoria di prevenzione in arrivo.", - "openLink": "Apri prevenzione" + "empty": "Nessun promemoria di controllo in arrivo.", + "openLink": "Apri controlli" }, "showMeasurements": "Mostra le misurazioni" }, diff --git a/messages/pl.json b/messages/pl.json index d227d654f..8535f958a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -238,7 +238,7 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", - "vorsorge": "Profilaktyka", + "vorsorge": "Badania", "illness": "Choroba" }, "auth": { @@ -3677,8 +3677,8 @@ "body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają." }, "vorsorge": { - "title": "Profilaktyka", - "body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza." + "title": "Badania", + "body": "Badania przypominają o przeglądach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza." }, "cycle": { "title": "Cykl", @@ -3702,7 +3702,7 @@ }, "export": { "title": "Raport dla lekarza", - "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." + "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i badania w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." }, "achievements": { "title": "Osiągnięcia", @@ -4132,8 +4132,8 @@ "subtitle": "Zarządzaj widokiem i kolejnością epizodów." }, "vorsorge": { - "title": "Profilaktyka", - "description": "Widok, kolejność i poszczególne przypomnienia profilaktyczne.", + "title": "Badania", + "description": "Widok, kolejność i poszczególne przypomnienia o badaniach.", "subtitle": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami." }, "modules": { @@ -6483,13 +6483,13 @@ "selectionBarLabel": "Akcje zaznaczenia" }, "measurementReminders": { - "sectionTitle": "Profilaktyka", + "sectionTitle": "Badania", "sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.", "addButton": "Dodaj przypomnienie", "markDone": "Gotowe", "delete": "Usuń", "deleteConfirmTitle": "Usunąć przypomnienie?", - "deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.", + "deleteConfirmDescription": "To przypomnienie o badaniu zostanie trwale usunięte.", "disabledBadge": "Wył.", "nextDue": { "none": "Niezaplanowane", @@ -6551,7 +6551,7 @@ "rruleHint": "RFC-5545, np. FREQ=YEARLY dla corocznego badania.", "firstDue": "Pierwszy termin (opcjonalnie)" }, - "pushTitle": "Przypomnienie profilaktyczne", + "pushTitle": "Przypomnienie o badaniu", "pushBody": "Czas na: {label}", "pushLocation": "(u {location})", "edit": "Edytuj", @@ -6562,7 +6562,7 @@ "measureNow": "Zmierz teraz", "nextDueLabel": "Następny termin:", "lastDoneLabel": "Ostatnio wykonano:", - "customize": "Dostosuj profilaktykę", + "customize": "Dostosuj badania", "adherence": { "label": "W interwale", "onTrack": "Zgodnie z planem", @@ -6578,8 +6578,8 @@ "empty": "Brak przypomnień do dostosowania." }, "dashboard": { - "empty": "Brak nadchodzących przypomnień profilaktycznych.", - "openLink": "Otwórz profilaktykę" + "empty": "Brak nadchodzących przypomnień o badaniach.", + "openLink": "Otwórz badania" }, "showMeasurements": "Pokaż pomiary" }, diff --git a/next.config.ts b/next.config.ts index 8b38781dc..2c6e0c4ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -134,6 +134,21 @@ const nextConfig: NextConfig = { destination: "/settings/notifications", permanent: true, }, + // v1.22.0 — the preventive-care surface moved from the German slug + // `/vorsorge` to the English `/checkups` (the feature reads "Checkups" + // in every non-German locale now). 301-redirect so bookmarks, the PWA's + // cached navigation, and any push deep-link keep resolving. The + // persisted internal id stays `vorsorge`; only the URL changes. + { + source: "/vorsorge", + destination: "/checkups", + permanent: true, + }, + { + source: "/vorsorge/:path*", + destination: "/checkups/:path*", + permanent: true, + }, ]; }, async headers() { diff --git a/src/app/vorsorge/page.tsx b/src/app/checkups/page.tsx similarity index 93% rename from src/app/vorsorge/page.tsx rename to src/app/checkups/page.tsx index 126f17539..162843a3c 100644 --- a/src/app/vorsorge/page.tsx +++ b/src/app/checkups/page.tsx @@ -12,7 +12,7 @@ import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-sec import { queryKeys } from "@/lib/query-keys"; /** - * v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated + * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated * feature surface for "wann muss ich was wo machen". Auth-gated; the * section component owns the list + create flow. * @@ -20,7 +20,7 @@ import { queryKeys } from "@/lib/query-keys"; * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. */ -export default function VorsorgePage() { +export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 89c8c6c7b..880650980 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -45,17 +45,17 @@ describe("nav-model destination list", () => { } }); - it("gives Vorsorge a top-level nav home in the clinical spine (N-3)", () => { + it("gives Checkups a top-level nav home in the clinical spine (N-3)", () => { const hrefs = NAV_DESTINATIONS.map((d) => d.href); - expect(hrefs).toContain("/vorsorge"); - const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge"); + expect(hrefs).toContain("/checkups"); + const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/checkups"); expect(vorsorge?.tKey).toBe("nav.vorsorge"); - // v1.19.1 (S4) — the fixed spine is Medications → Vorsorge → Labs → - // Illness → Insights, so Vorsorge sits after Medications and before Labs. + // v1.19.1 (S4) — the fixed spine is Medications → Checkups → Labs → + // Illness → Insights, so Checkups sits after Medications and before Labs. expect(hrefs.indexOf("/medications")).toBeLessThan( - hrefs.indexOf("/vorsorge"), + hrefs.indexOf("/checkups"), ); - expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/labs")); + expect(hrefs.indexOf("/checkups")).toBeLessThan(hrefs.indexOf("/labs")); expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/insights")); }); @@ -170,7 +170,7 @@ describe("visibleNavDestinations module gate", () => { expect(emptyMap).toContain("/measurements"); expect(emptyMap).toContain("/medications"); expect(emptyMap).toContain("/insights"); - expect(emptyMap).toContain("/vorsorge"); + expect(emptyMap).toContain("/checkups"); }); }); @@ -210,7 +210,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { // Feature surfaces that left the always-visible strip stay reachable. expect(hub).toContain("/measurements"); expect(hub).toContain("/mood"); - expect(hub).toContain("/vorsorge"); + expect(hub).toContain("/checkups"); // The shared utility tail rides at the end. expect(hub).toContain("/bugreport"); expect(hub).toContain("/settings/account"); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 049694844..c5a0d91df 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -142,7 +142,7 @@ describe(" module gating (v1.18.0)", () => { expect(html).toContain('href="/measurements"'); expect(html).toContain('href="/medications"'); expect(html).toContain('href="/insights"'); - expect(html).toContain('href="/vorsorge"'); + expect(html).toContain('href="/checkups"'); }); it("renders a module's nav entry when its module is enabled", () => { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index c0c94a18d..cd260adf1 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -104,7 +104,7 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // render — not buried three taps deep under Settings → Reminders. The // Reminders hub still links to it; this is the direct front door. { - href: "/vorsorge", + href: "/checkups", tKey: "nav.vorsorge", icon: Stethoscope, tourId: "nav-vorsorge", diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index 84d69dd7f..c27186808 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -5,7 +5,7 @@ * * A compact chart-row card listing the next few due reminders with an * inline primary action per row — the same done-vs-capture branch the - * dedicated `/vorsorge` page surfaces, scaled down for the dashboard. + * dedicated `/checkups` page surfaces, scaled down for the dashboard. * A free-text / self-planned exam marks done silently; a measurement- * linked reminder opens the real measurement-entry form and satisfies * the reminder in the same action. @@ -109,7 +109,7 @@ export function VorsorgeDashboardCard() { {t("measurementReminders.dashboard.empty")}

      diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts index 6106f2b67..2e2fd3a48 100644 --- a/src/lib/onboarding/tour-state.ts +++ b/src/lib/onboarding/tour-state.ts @@ -137,7 +137,7 @@ const ALL_STOPS: readonly TourStop[] = [ id: "vorsorge", targetId: "vorsorge-hero", placement: "bottom", - route: "/vorsorge", + route: "/checkups", titleKey: "onboarding.tour.steps.vorsorge.title", bodyKey: "onboarding.tour.steps.vorsorge.body", }, From e097e57c84f143e82008d1ed771fd87b7d7c760c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:48:48 +0200 Subject: [PATCH 06/32] feat(labs): add a hidden flag to the biomarker catalog Add a `hidden` boolean to the Biomarker model (migration 0195_biomarker_hidden) so a marker the user no longer needs can be soft-removed instead of deleted: it leaves the active catalog and the lab-entry pickers but keeps its readings and canonical unit/range. Thread `hidden` through the DTO, the update schema, the PUT handler (field-by-field), and the list endpoint (visible markers first). Regen the OpenAPI contract. --- docs/api/openapi.yaml | 9 ++++++++- .../0195_biomarker_hidden/migration.sql | 9 +++++++++ prisma/schema.prisma | 4 ++++ src/app/api/biomarkers/[id]/route.ts | 3 +++ src/app/api/biomarkers/route.ts | 6 +++++- src/components/labs/types.ts | 2 ++ src/lib/openapi/routes/biomarkers.ts | 5 +++-- .../validations/__tests__/biomarkers.test.ts | 17 +++++++++++++++++ src/lib/validations/biomarkers.ts | 3 +++ 9 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/0195_biomarker_hidden/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17cfd4dd7..1138e83df 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6741,6 +6741,8 @@ paths: - type: string const: "" - type: "null" + hidden: + type: boolean responses: "200": description: Updated biomarker. @@ -20212,6 +20214,8 @@ components: anyOf: - type: string - type: "null" + hidden: + type: boolean createdAt: type: string updatedAt: @@ -20225,11 +20229,14 @@ components: - panel - hasContext - context + - hidden - createdAt - updatedAt additionalProperties: false description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. A `LabResult` linking this marker resolves its unit + reference bounds from here. + presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it + (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds + from here. CreateBiomarkerResponse: type: object properties: diff --git a/prisma/migrations/0195_biomarker_hidden/migration.sql b/prisma/migrations/0195_biomarker_hidden/migration.sql new file mode 100644 index 000000000..6d0eba592 --- /dev/null +++ b/prisma/migrations/0195_biomarker_hidden/migration.sql @@ -0,0 +1,9 @@ +-- v1.22.0 — Biomarker catalog: per-marker "hidden" flag. +-- +-- A marker the user no longer needs is hidden rather than deleted: it leaves +-- the active catalog list and the lab-entry pickers, but its readings and its +-- canonical unit/reference-range definition survive (unhide restores it). A +-- plain additive boolean column with a server-side default; no existing row is +-- touched (every back-filled row defaults to visible). `IF NOT EXISTS` makes +-- the rerun safe. +ALTER TABLE "biomarkers" ADD COLUMN IF NOT EXISTS "hidden" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0c8bb68b..bd9779f55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3657,6 +3657,10 @@ model Biomarker { /// Optional panel grouping moved off the per-entry row, e.g. /// "Großes Blutbild". NULL = standalone marker. panel String? + /// A marker the user no longer needs: dropped from the active catalog list + /// and from the lab-entry pickers, but never deleted — its readings and + /// canonical unit/range definition survive. + hidden Boolean @default(false) @map("hidden") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 87ec6f236..3b533241b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -39,6 +39,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -54,6 +55,7 @@ function serialiseBiomarker(row: { context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) : null, + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -137,6 +139,7 @@ export const PUT = apiHandler( if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound; if (d.upperBound !== undefined) data.upperBound = d.upperBound; if (d.panel !== undefined) data.panel = d.panel; + if (d.hidden !== undefined) data.hidden = d.hidden; // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b30c84be6..e4c753bee 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -50,6 +50,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -63,6 +64,7 @@ function serialiseBiomarker(row: { panel: row.panel, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -71,9 +73,11 @@ function serialiseBiomarker(row: { export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Visible markers first, then hidden — the manager renders them in two + // sections and the lab-entry pickers drop the hidden tail client-side. const rows = await prisma.biomarker.findMany({ where: { userId: user.id }, - orderBy: [{ name: "asc" }], + orderBy: [{ hidden: "asc" }, { name: "asc" }], }); annotate({ diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 1c10ef77a..a3448eb69 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -42,6 +42,8 @@ export interface BiomarkerDto { panel: string | null; hasContext: boolean; context: string | null; + /** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */ + hidden: boolean; createdAt: string; updatedAt: string; } diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 5f3aa1e9e..11f27dca8 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -30,7 +30,7 @@ createBiomarkerSchema.meta({ updateBiomarkerSchema.meta({ id: "UpdateBiomarkerRequest", description: - "Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.", + "Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409.", }); const biomarkerRow = z @@ -43,13 +43,14 @@ const biomarkerRow = z panel: z.string().nullable(), hasContext: z.boolean(), context: z.string().nullable(), + hidden: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/src/lib/validations/__tests__/biomarkers.test.ts b/src/lib/validations/__tests__/biomarkers.test.ts index de69fe151..40eaab85f 100644 --- a/src/lib/validations/__tests__/biomarkers.test.ts +++ b/src/lib/validations/__tests__/biomarkers.test.ts @@ -72,4 +72,21 @@ describe("updateBiomarkerSchema", () => { }); expect(r.success).toBe(false); }); + + it("accepts a hidden toggle", () => { + const r = updateBiomarkerSchema.safeParse({ hidden: true }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.hidden).toBe(true); + }); + + it("leaves hidden undefined when omitted", () => { + const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.hidden).toBeUndefined(); + }); + + it("rejects a non-boolean hidden", () => { + const r = updateBiomarkerSchema.safeParse({ hidden: "yes" }); + expect(r.success).toBe(false); + }); }); diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index 23a9e4ebb..7718fa087 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -72,6 +72,9 @@ export const updateBiomarkerSchema = z upperBound: upperBound.or(z.null()), context: optionalContext.or(z.null()), panel: optionalPanel.or(z.null()), + // v1.22 — hide / unhide a marker. An omitted key leaves visibility + // untouched; `true` drops the marker from the active list + pickers. + hidden: z.boolean().optional(), }) .refine( (d) => From 3ad59cde98679ba80f0d7c19150dc3012d94bd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:48:54 +0200 Subject: [PATCH 07/32] feat(labs): compact editable biomarker list with hide Rebuild the biomarker manager as compact rows (name + unit inline, reference range / panel on a muted sub-line) with a hide toggle, an edit button, and a delete, replacing the one-card-per-marker layout. Hidden markers collect in a separate restorable section. Filter hidden markers out of the lab-entry picker, keeping the currently selected one visible so an existing reading still resolves its label. Touch targets meet the 44px mobile floor (size-11 sm:size-9). --- messages/de.json | 9 +- messages/en.json | 9 +- src/components/labs/biomarker-manager.tsx | 174 ++++++++++++++-------- src/components/labs/lab-form.tsx | 8 +- 4 files changed, 134 insertions(+), 66 deletions(-) diff --git a/messages/de.json b/messages/de.json index c07f8cf1f..5db331c6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6720,7 +6720,14 @@ "savedToast": "Biomarker gespeichert", "updatedToast": "Biomarker aktualisiert", "saveError": "Biomarker konnte nicht gespeichert werden" - } + }, + "hide": "Ausblenden", + "unhide": "Wiederherstellen", + "hiddenToast": "Biomarker ausgeblendet", + "unhiddenToast": "Biomarker wiederhergestellt", + "hideError": "Biomarker konnte nicht aktualisiert werden", + "allHidden": "Alle Biomarker sind ausgeblendet.", + "hiddenSection": "Ausgeblendet" }, "catalog": { "total-cholesterol": "Gesamtcholesterin", diff --git a/messages/en.json b/messages/en.json index 4e8e9c6f5..cf1ac2738 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6720,7 +6720,14 @@ "savedToast": "Biomarker saved", "updatedToast": "Biomarker updated", "saveError": "Could not save the biomarker" - } + }, + "hide": "Hide", + "unhide": "Restore", + "hiddenToast": "Biomarker hidden", + "unhiddenToast": "Biomarker restored", + "hideError": "Could not update the biomarker", + "allHidden": "Every biomarker is hidden.", + "hiddenSection": "Hidden" }, "catalog": { "total-cholesterol": "Total cholesterol", diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 1bcfce316..8608c0a8c 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -2,16 +2,15 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Pencil, Plus } from "lucide-react"; +import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -21,12 +20,18 @@ import { BiomarkerForm } from "./biomarker-form"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; /** - * v1.18.1 — the Biomarker catalog manager. + * v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows. * * Lives on the Labs page (not Settings) because the catalog IS the Labs - * feature's primary object. Lists every defined marker with its unit + - * reference range, opens a define-new / edit sheet, and wires delete (the - * `onDelete: SetNull` FK keeps existing readings, just unlinks them). + * feature's primary object. Each marker renders as a single compact row + * (name + unit inline, reference range / panel on a muted sub-line) with a + * hide toggle, an edit button (reusing the define/edit sheet), and a delete + * (the `onDelete: SetNull` FK keeps existing readings, just unlinks them). + * + * "Hide" is a soft remove: a marker the user no longer needs leaves the + * active list and the lab-entry pickers but keeps its readings + canonical + * unit/range. Hidden markers collect in a separate section where they can be + * restored or deleted outright. */ export function BiomarkerManager() { const { t } = useTranslations(); @@ -50,6 +55,20 @@ export function BiomarkerManager() { onError: () => toast.error(t("labs.biomarker.deleteError")), }); + const hiddenMutation = useMutation({ + mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) => + apiPut(`/api/biomarkers/${id}`, { hidden }), + onSuccess: (_data, { hidden }) => { + toast.success( + hidden + ? t("labs.biomarker.hiddenToast") + : t("labs.biomarker.unhiddenToast"), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + }, + onError: () => toast.error(t("labs.biomarker.hideError")), + }); + function openNew() { setEditing(null); setFormOpen(true); @@ -68,6 +87,73 @@ export function BiomarkerManager() { } const markers = data?.biomarkers ?? []; + const visible = markers.filter((m) => !m.hidden); + const hidden = markers.filter((m) => m.hidden); + + function renderRow(marker: BiomarkerDto) { + const range = formatReferenceRange( + marker.lowerBound, + marker.upperBound, + formatLabValue, + ); + return ( +
    • +
      +
      + {marker.name} + {marker.unit} +
      +

      + {range + ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` + : t("labs.biomarker.noRange")} + {marker.panel ? ` · ${marker.panel}` : ""} +

      +
      + + + deleteMutation.mutate(marker.id)} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
    • + ); + } return (
      @@ -84,7 +170,7 @@ export function BiomarkerManager() { {isLoading ? (
      {Array.from({ length: 3 }, (_, i) => ( - + ))}
      ) : isError ? ( @@ -101,60 +187,24 @@ export function BiomarkerManager() { } /> ) : ( -
        - {markers.map((marker) => { - const range = formatReferenceRange( - marker.lowerBound, - marker.upperBound, - formatLabValue, - ); - return ( -
      • - - -
        -
        - - {marker.name} - - - {marker.unit} - -
        -

        - {range - ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` - : t("labs.biomarker.noRange")} - {marker.panel ? ` · ${marker.panel}` : ""} -

        -
        -
        - - deleteMutation.mutate(marker.id)} - title={t("labs.biomarker.deleteConfirmTitle")} - description={t( - "labs.biomarker.deleteConfirmDescription", - )} - confirmLabel={t("labs.biomarker.delete")} - className="size-9" - iconClassName="h-4 w-4" - /> -
        -
        -
        -
      • - ); - })} -
      +
      + {visible.length > 0 ? ( +
        {visible.map(renderRow)}
      + ) : ( +

      + {t("labs.biomarker.allHidden")} +

      + )} + + {hidden.length > 0 ? ( +
      +

      + {t("labs.biomarker.hiddenSection")} +

      +
        {hidden.map(renderRow)}
      +
      + ) : null} +
      )} m.id === biomarkerId); + const allMarkers = catalog?.biomarkers ?? []; + const selected = allMarkers.find((m) => m.id === biomarkerId); + // v1.22 — hidden markers drop from the picker, but keep the currently + // selected one visible (e.g. editing a reading whose marker was later + // hidden) so the Select still resolves its label. + const markers = allMarkers.filter((m) => !m.hidden || m.id === biomarkerId); function handleSelect(next: string) { if (next === DEFINE_NEW) { From 0bde6545b6bb2ffdb34197c17d4e8ff1fa676b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 12:49:00 +0200 Subject: [PATCH 08/32] fix(labs): shared primitives in lab views, drop value Learn link Replace the hand-rolled native scan dropzone button with the shared Button primitive, swap the inline-style chart height for a height class, and remove the Learn link from the biomarker value view where it does not belong. --- src/components/labs/lab-biomarker-chart.tsx | 3 +-- src/components/labs/lab-biomarker-detail.tsx | 3 --- src/components/labs/ocr-review-dialog.tsx | 7 ++++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index c1a5521e7..dca2f95f7 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -14,7 +14,6 @@ import { } from "recharts"; import { Button } from "@/components/ui/button"; -import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { formatDateShort } from "@/lib/format"; import { formatLabValue } from "@/lib/labs/format-value"; @@ -128,7 +127,7 @@ export function LabBiomarkerChart({ {t("labs.chart.empty")}

      ) : ( -
      +
      {marker.context}

      ) : null} - - {isLoading ? ( diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index ff7e8d49d..842bd959f 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -220,11 +220,12 @@ export function OcrReviewDialog({ if (file) onFilePicked(file); }} /> - +
      ) : (
      From f5e9d875cc0bc03c97a246a77352aea629b861bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:02:05 +0200 Subject: [PATCH 09/32] Remove the trailing info glyph across the insights surface The metric sub-page heading carried a trailing info "i" that opened a reference-guidance tooltip on every routed insights page, and four card satellites repeated the pattern (mood statistical caveat, health-score delta read, period-narrative provenance, derived-score provenance). Drop the glyph and its disclosure everywhere. Where the disclosure held the only copy of a descriptive line, that line now reads inline as a muted caption so the information stays visible instead of hiding behind a hover affordance. --- .../coincident-deviation-card.test.tsx | 4 +- .../__tests__/health-score-card.test.tsx | 17 +- .../health-score-delta-explainer.test.tsx | 135 +++---------- .../__tests__/provenance-explainer.test.tsx | 25 +-- .../__tests__/score-anatomy-view.test.tsx | 4 +- .../insights/derived/provenance-explainer.tsx | 186 ++---------------- .../insights/health-score-delta-explainer.tsx | 160 +++------------ .../mood-discovered-relations.test.tsx | 23 ++- .../insights/mood/mood-explainer-icon.tsx | 53 ++--- .../insights/period-narrative-card.tsx | 53 ++--- src/components/insights/sub-page-shell.tsx | 35 +--- 11 files changed, 150 insertions(+), 545 deletions(-) diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index ace97f219..17ed6ec87 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -198,8 +198,8 @@ describe("", () => { // At most amber — never destructive/red. expect(html).toContain("border-warning"); expect(html).not.toContain("text-destructive"); - // The provenance affordance reaches the card. - expect(html).toContain('data-slot="provenance-explainer-trigger"'); + // The provenance method reaches the card. + expect(html).toContain('data-slot="provenance-explainer-method"'); // The state change is announced politely to SR users (not assertively). expect(html).toContain('role="status"'); expect(html).not.toContain('role="alert"'); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index bc4cee525..ef0ee82e5 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -129,10 +129,11 @@ describe("", () => { }); it("threads aria-describedby from the delta text to the explainer body", () => { - // FB-I1 a11y — the delta `` carries aria-describedby; the - // explainer trigger's aria-controls reads the same id. Both - // attributes share one `useId()` thread so SR can connect - // "−3 vs last week" to the three-sentence read. + // FB-I1 a11y — the delta `` carries aria-describedby; the inline + // explainer caption carries the same id. v1.22 — the read no longer + // hides behind a "?" trigger (no aria-controls); the body renders inline + // and the shared `useId()` thread still connects "−3 vs last week" to the + // three-sentence read. const html = ssr( ", () => { expect(deltaSpan).not.toBeNull(); const threadedId = deltaSpan?.[1] ?? ""; expect(threadedId).not.toBe(""); - // The same id sits on the trigger button's aria-controls. - expect(html).toContain(`aria-controls="${threadedId}"`); + // The same id sits on the inline explainer body. + expect(html).toMatch( + new RegExp( + `]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`, + ), + ); }); it("renders four component rows with their values", () => { diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx index 05ed40648..ba54d1f8c 100644 --- a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx +++ b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx @@ -4,16 +4,15 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer"; /** - * v1.4.28 R3c-Insights — FB-I1 — the "?" affordance next to the delta - * line opens a 3-sentence read of which components contributed, what - * the comparison window is, and one concrete next step. + * v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a + * three-sentence read of which components contributed, what the comparison + * window is, and one concrete next step. * - * Tests run through SSR so they don't depend on a browser surface. - * The popover / sheet body mounts on click on the live page; the - * SSR snapshot captures the trigger button (always present) plus a - * locale-grounded check that the body copy actually exists in the - * translation payload — the load-bearing constraint is "every - * locale ships the 3-sentence read", not the popover open animation. + * v1.22 — the read no longer hides behind a "?" glyph + popover; it renders + * inline as a muted caption beside the delta line. These tests run through + * SSR (no browser surface) and pin: the inline body paints by default, it + * threads the parent-supplied id for aria-describedby, and every locale + * ships the body copy. */ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -23,114 +22,42 @@ function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { } /** - * Helper that paints the explainer body via the same `t()` call the - * popover uses. If a future refactor drops a locale key the test - * fails at the `t()` call site rather than silently rendering the - * fallback chevron. + * Helper that paints the explainer body via the same `t()` call the caption + * uses. If a future refactor drops a locale key the test fails at the `t()` + * call site rather than silently rendering the literal key path. */ function BodyProbe() { const { t } = useTranslations(); return ( -
      - - {t("insights.healthScore.deltaExplainer.trigger")} - - - {t("insights.healthScore.deltaExplainer.title")} - - - {t("insights.healthScore.deltaExplainer.body")} - -
      + + {t("insights.healthScore.deltaExplainer.body")} + ); } describe("", () => { - it("renders the icon-only trigger button with an accessible label (EN)", () => { + it("renders the read inline as a muted caption (no trigger glyph)", () => { const html = ssr(); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, + expect(html).toContain('data-slot="health-score-delta-explainer-body"'); + // The old icon-only disclosure trigger is gone. + expect(html).not.toContain( + 'data-slot="health-score-delta-explainer-trigger"', ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain('aria-label="What does this delta mean?"'); - }); - - it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => { - // The glyph stays 12 px but the click surface inflates to 44 px - // via `min-h-11 min-w-11` on the trigger; the parent row keeps - // its 16 px stride because `-my-3 -mx-2` swallows the extra - // reach. Pinning so a future refactor can't drop back to the - // visual-chip-as-hit-target shape that failed WCAG 2.5.5. - const html = ssr(); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain("min-h-11"); - expect(trigger?.[0]).toContain("min-w-11"); - }); - - it("threads aria-expanded + aria-controls onto the trigger button", () => { - // SR users get a clear "this opens a disclosure" cue via - // aria-expanded; the aria-controls value is the same id the - // body paints so the SR can jump to the explanation after - // the user activates the trigger. - const html = ssr(); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain("aria-expanded"); - expect(trigger?.[0]).toContain("aria-controls"); + expect(html).not.toContain("aria-expanded"); }); it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => { - // Parent owns the id and paints aria-describedby on the delta - // span; the explainer re-uses the same id for its - // aria-controls thread. The shared id is the seam SR uses to - // jump from the digit to the explanation. const html = ssr( , ); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, + expect(html).toMatch( + /]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/, ); - expect(trigger?.[0]).toContain('aria-controls="parent-id-x"'); }); - it("does not wrap the trigger in a clickable (single interactive element)", () => { - // The earlier mobile path wrapped the button in a - // `` which created two interactive - // elements in the a11y tree and intercepted clicks on the 2 px - // gap around the button. The button now owns the open toggle - // directly. Pinning the negative so a future refactor can't - // reintroduce the wrapper. + it("paints the body copy on the initial SSR snapshot (always visible)", () => { const html = ssr(); - expect(html).not.toMatch(/]*onclick/i); - }); - - it("renders the trigger in German with the localised aria-label", () => { - const html = ssr(, "de"); - const trigger = html.match( - /]*data-slot="health-score-delta-explainer-trigger"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain( - 'aria-label="Was bedeutet diese Veränderung?"', - ); - }); - - it("does not paint the popover/sheet body on the initial SSR snapshot", () => { - // Closed-by-default contract: the trigger button is the only - // surface the SSR snapshot owns; the body lives in a portal that - // mounts after the user taps. Pinning the negative side keeps a - // future "open on render" refactor from leaking long-form copy - // into the static markup. - const html = ssr(); - expect(html).not.toContain('data-slot="health-score-delta-explainer-body"'); - expect(html).not.toContain( - 'data-slot="health-score-delta-explainer-title"', - ); + expect(html).toContain("BP, weight, mood and medication adherence"); }); it("ships the EN body copy through the locale payload (3-sentence read)", () => { @@ -171,20 +98,18 @@ describe("", () => { expect(text).toContain("vor einer Woche"); }); - it("ships the trigger + title + body keys in every locale supported by R3c-Insights", () => { - // Pinning every locale stops a future feature branch from - // shipping an English fallback for the new explainer keys. + it("ships the body key in every locale supported by R3c-Insights", () => { + // Pinning every locale stops a future feature branch from shipping an + // English fallback for the explainer body key. for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { const html = renderToStaticMarkup( , ); - // The probe re-uses the same `t()` payload the popover does, - // so any missing key surfaces as the literal key path which - // would never contain a space. - expect(html).not.toContain("insights.healthScore.deltaExplainer.trigger"); - expect(html).not.toContain("insights.healthScore.deltaExplainer.title"); + // The probe re-uses the same `t()` payload the caption does, so any + // missing key surfaces as the literal key path which never contains a + // space. expect(html).not.toContain("insights.healthScore.deltaExplainer.body"); } }); diff --git a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx index 4bcee28f3..69ed6e1d9 100644 --- a/src/components/insights/derived/__tests__/provenance-explainer.test.tsx +++ b/src/components/insights/derived/__tests__/provenance-explainer.test.tsx @@ -26,7 +26,7 @@ const EMPTY_PROVENANCE: DerivedProvenance = { }; describe("", () => { - it("renders an accessible trigger that controls the body (collapsed by default)", () => { + it("renders the method inline as a muted caption (no trigger glyph)", () => { const html = render( ", () => { bodyId="prov-1" />, ); - expect(html).toContain('data-slot="provenance-explainer-trigger"'); - expect(html).toContain('aria-expanded="false"'); - expect(html).toContain('aria-controls="prov-1"'); - expect(html).toContain("aria-label"); + expect(html).toContain('data-slot="provenance-explainer-method"'); + expect(html).toContain("BMI is weight divided by height squared."); + expect(html).toContain('id="prov-1"'); + // The old icon-only disclosure trigger is gone. + expect(html).not.toContain('data-slot="provenance-explainer-trigger"'); }); - it("meets the 44px hit-target floor on the trigger", () => { - const html = render( - , - ); - expect(html).toContain("min-h-11"); - expect(html).toContain("min-w-11"); - }); - - it("renders the trigger for the empty-inputs / bad-date provenance without throwing", () => { + it("renders the empty-inputs / bad-date provenance without throwing", () => { const html = render( , ); - expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="provenance-explainer-method"'); }); it("accepts a cited standard prop without erroring", () => { @@ -69,6 +62,6 @@ describe("", () => { }} />, ); - expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="provenance-explainer-method"'); }); }); diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx index 7329346b2..cd193728f 100644 --- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -55,9 +55,9 @@ describe("", () => { // The missing contributor renders present=false (dropped, not blank). expect(html).toContain('data-contributor="respiratory"'); expect(html).toContain('data-present="false"'); - expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="provenance-explainer-method"'); // v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed; - // the non-clinical framing now lives behind the provenance explainer. + // the non-clinical framing now lives in the inline provenance method. expect(html).not.toContain('data-slot="score-anatomy-disclaimer"'); }); diff --git a/src/components/insights/derived/provenance-explainer.tsx b/src/components/insights/derived/provenance-explainer.tsx index c3058e167..71d00b017 100644 --- a/src/components/insights/derived/provenance-explainer.tsx +++ b/src/components/insights/derived/provenance-explainer.tsx @@ -1,31 +1,19 @@ "use client"; -import { useId, useState } from "react"; -import { ExternalLink, Info } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { DerivedProvenance } from "@/lib/insights/derived/types"; /** - * v1.10.0 — the "how this is computed / standard cited" surface. + * v1.10.0 — the "how this is computed" surface. * - * A collapsible `ⓘ` explainer that renders, as React text children (NO - * markdown library — the XSS rule), the metric's inputs, a plain-language - * method, and an optional link to the cited standard (IEEE / RFC / WHO / - * LOINC / Wikipedia). Reads the `provenance` field off `Derived` for the - * inputs chip + the "as of" + window framing. - * - * Surface follows the shipped `health-score-delta-explainer` pattern: - * Popover on `md+`, bottom-sheet on phone-class viewports via - * `ResponsiveSheet`. Owns its own open state. aria-describedby threads the - * trigger to the body. + * v1.22 — the collapsible `ⓘ` glyph that gated the method/inputs/window read + * behind a popover / bottom-sheet is removed; the maintainer wants the + * trailing info affordance gone across the insights surface. The + * plain-language method now renders inline as a muted caption (React text + * children only — the XSS rule still forbids a markdown library), so the + * "how computed" line is always visible rather than disclosure-only. The + * `provenance`, `standard` and `title` props are retained for callers; the + * inputs-chip / window / cited-standard detail folded out with the popover. */ export interface ProvenanceStandard { @@ -43,167 +31,31 @@ export interface ProvenanceExplainerProps { * passes a localised string; the component never parses markup. */ method: React.ReactNode; - /** Optional cited standard rendered as an external link in the footer. */ + /** Optional cited standard. Retained for callers. */ standard?: ProvenanceStandard; - /** Accessible title of the surface (sheet header / popover first line). */ + /** Accessible title of the surface. Retained for callers. */ title?: string; - /** Optional className for the trigger button. */ + /** Optional className for the caption. */ className?: string; /** Optional id the parent threads as aria-describedby on its own element. */ bodyId?: string; } export function ProvenanceExplainer({ - provenance, method, - standard, - title, className, bodyId, }: ProvenanceExplainerProps) { - const { t, locale } = useTranslations(); - const isMobile = useIsMobile(); - const [open, setOpen] = useState(false); - const generatedId = useId(); - const resolvedBodyId = bodyId ?? generatedId; - - const resolvedTitle = title ?? t("insights.derived.provenance.title"); - const triggerLabel = t("insights.derived.provenance.trigger"); - - const asOf = (() => { - const parsed = new Date(provenance.computedAt); - if (Number.isNaN(parsed.getTime())) return null; - try { - return new Intl.DateTimeFormat(locale, { - year: "numeric", - month: "short", - day: "2-digit", - }).format(parsed); - } catch { - return null; - } - })(); - - const body = ( -
      -
      -

      - {t("insights.derived.provenance.inputsLabel")} -

      - {provenance.inputs.length > 0 ? ( -
        - {provenance.inputs.map((input) => ( -
      • - {input} -
      • - ))} -
      - ) : ( -

      - {t("insights.derived.provenance.noInputs")} -

      - )} -
      - -
      -

      - {t("insights.derived.provenance.methodLabel")} -

      -

      - {method} -

      -
      - -

      - {t("insights.derived.provenance.window", { - days: provenance.windowDays, - source: provenance.source, - })} - {asOf - ? ` · ${t("insights.derived.provenance.asOf", { date: asOf })}` - : ""} -

      - - {standard && ( - - {t("insights.derived.provenance.standardLink", { - name: standard.name, - })} - - )} -
      - ); - - const trigger = ( - - ); - - if (isMobile) { - return ( - <> - {trigger} - -
      {body}
      -
      - - ); - } - - return ( - - {trigger} - -

      - {resolvedTitle} -

      - {body} -
      -
      + {method} +
      ); } diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx index 89998fc69..68fc30720 100644 --- a/src/components/insights/health-score-delta-explainer.tsx +++ b/src/components/insights/health-score-delta-explainer.tsx @@ -1,167 +1,63 @@ "use client"; -import { useId, useState } from "react"; -import { HelpCircle } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useId } from "react"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; /** - * v1.4.28 R3c-Insights — explain the "vs last week" delta on tap. + * v1.4.28 R3c-Insights — explain the "vs last week" delta. * - * FB-I1: the user asked why the headline number moved. The earlier - * delta line surfaced the digit ("-3 vs last week") without any - * mention of which components shifted, what the comparison window - * actually is, or what the user can do about it. The explainer is a - * single icon-only `?` button next to the delta line that opens a - * three-sentence read. + * v1.22 — the icon-only `?` glyph that gated the three-sentence read behind + * a popover / bottom-sheet is removed; the maintainer wants the trailing + * help affordance gone across the insights surface. The descriptive read + * now renders inline as a muted caption beside the delta line, so it is + * always visible rather than disclosure-only. The body keeps its id so the + * parent's `aria-describedby` on the delta digit still threads to it. * - * Surface choice follows R1.1 §1 — popover on `md+`, bottom-sheet - * on phone-class viewports. Both share the same body copy via the - * `insights.healthScore.deltaExplainer.body` key; the title is - * surfaced as the popover-content first line on desktop and as the - * sheet header on mobile (the ResponsiveSheet primitive already - * paints a header band on the sheet branch). - * - * Pure presentational. Owns its own open state — the parent doesn't - * need to thread an extra prop through. + * Operator suppression is preserved: when `healthScoreExplainer` is off the + * caption does not render (the parent delta digit stays visible because the + * parent paints it directly). */ interface HealthScoreDeltaExplainerProps { - /** - * The delta value the parent already painted on the line above. The - * explainer doesn't repaint the digit; it sits next to the line - * with the `?` glyph and lets the user request the prose. - */ + /** The delta value the parent paints on the line above. Retained for callers. */ delta: number; - /** - * Optional className for the trigger button so the parent can - * align the glyph to the delta line's baseline. - */ + /** Optional className for the caption so the parent can tune its spacing. */ className?: string; /** - * Optional id the parent owns and threads to the explainer body via - * the popover/sheet markup. The same id sits on the parent's delta - * `` as `aria-describedby` so screen readers can connect "−3 - * vs last week" to the three-sentence read on demand. + * Optional id the parent owns and threads to the caption. The same id sits + * on the parent's delta `` as `aria-describedby` so screen readers + * connect the digit to the read. */ bodyId?: string; } export function HealthScoreDeltaExplainer({ - delta, className, bodyId, }: HealthScoreDeltaExplainerProps) { const { t } = useTranslations(); - const isMobile = useIsMobile(); const flags = useFeatureFlags(); - const [open, setOpen] = useState(false); - // Stable fallback id when the parent doesn't supply one. The body - // still paints the id so future consumers can thread the same - // describedby pattern without modifying the explainer. + // Stable fallback id when the parent doesn't supply one. The hook sits + // above the conditional return so the hook order stays stable. const generatedId = useId(); const resolvedBodyId = bodyId ?? generatedId; - // v1.4.31 — operator can hide the `?` trigger; the delta digit on - // the parent line stays visible because the parent reads it - // directly. Silent suppression per the architecture brief. The - // early return sits below every hook call so the hook order stays - // stable across renders. + + // v1.4.31 — operator can hide the read; silent suppression per the + // architecture brief. if (!flags.healthScoreExplainer) return null; - const triggerLabel = t("insights.healthScore.deltaExplainer.trigger"); - const title = t("insights.healthScore.deltaExplainer.title"); - const body = t("insights.healthScore.deltaExplainer.body"); - - // Direct click handler on the button on both branches. Earlier - // mobile path wrapped the button in a `` - // which created two interactive elements in the a11y tree and - // intercepted clicks on the 2 px gap around the button. The button - // already handles Enter/Space natively; the only thing the wrapper - // owned was the open toggle, which moves onto the button itself. - const handleOpen = () => setOpen(true); - - const trigger = ( - - ); - - if (isMobile) { - return ( - <> - {trigger} - 0 ? `+${delta}` : `${delta}`, - })} - > -

      - {body} -

      -
      - - ); - } - - return ( - - {trigger} - -

      - {title} -

      -

      - {body} -

      -
      -
      + {t("insights.healthScore.deltaExplainer.body")} +
      ); } diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx index 63151a0f2..7dde14d36 100644 --- a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx +++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx @@ -69,10 +69,11 @@ describe("", () => { // factor label resolves to the localized measurement name expect(html).toContain("Time in Daylight"); expect(html).toContain("higher next-day mood"); - // The n/r/q detail rides a focusable explainer icon (its tooltip content - // is portalled on open, so it is absent from the static markup). Assert - // the icon is wired per pair instead of the inline stat string. - expect(html).toContain('aria-label="How this was computed"'); + // v1.22 — the n/r/q detail no longer hides behind an "i" glyph + tooltip; + // it reads inline as a muted caption, so the stat string is present in the + // static markup. + expect(html).toContain('data-slot="mood-explainer-detail"'); + expect(html).toContain("40 paired days"); }); it("phrases a mood → next-day outcome pair", () => { @@ -85,7 +86,7 @@ describe("", () => { expect(html).toContain("lower next-day"); }); - it("filters to mood pairs and folds the full-family caveat into the subsection explainer", () => { + it("filters to mood pairs and reads the full-family caveat inline", () => { const html = renderWith( [ pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }), @@ -96,14 +97,12 @@ describe("", () => { expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe( 1, ); - // The false-discovery footer + the observational disclaimer fold into the - // subsection explainer icon; its tooltip content is portalled on open, so - // assert the trigger exists rather than the (now hidden) footer string. + // v1.22 — the false-discovery footer + observational disclaimer no longer + // hide behind an "i" glyph + tooltip; they read inline as a muted caption, + // so both the caption slot and its text are present in the static markup. expect( - (html.match(/aria-label="How this was computed"/g) ?? []).length, + (html.match(/data-slot="mood-explainer-detail"/g) ?? []).length, ).toBeGreaterThanOrEqual(1); - // The former muted explainer paragraph + inline footnote rows are gone. - expect(html).not.toContain("day-to-day pairs tested"); - expect(html).not.toContain("hold up after correcting"); + expect(html).toContain("day-to-day pairs tested"); }); }); diff --git a/src/components/insights/mood/mood-explainer-icon.tsx b/src/components/insights/mood/mood-explainer-icon.tsx index 8ccc0a35d..c65ca0295 100644 --- a/src/components/insights/mood/mood-explainer-icon.tsx +++ b/src/components/insights/mood/mood-explainer-icon.tsx @@ -1,55 +1,34 @@ "use client"; -import { Info } from "lucide-react"; - -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; /** - * v1.12.4 (C3/C4) — compact explainer affordance for the mood surface. + * v1.12.4 (C3/C4) — compact explainer for the mood surface. * - * Replaces the low-density inline "how this was computed" text rows - * (paired-day counts, the false-discovery footer, the observational - * disclaimer) with a single ring/info icon. The detail moves into a - * hover/focus tooltip so the surface stops spending a full-width row on a - * footnote. The trigger is a real focusable `button` carrying an - * `aria-label`, so the explanation is reachable by keyboard and screen - * readers — not hover-only. Self-contained `TooltipProvider` so callers - * don't need an ambient one. + * v1.22 — the round info glyph that hid the statistical caveat behind a + * hover/focus tooltip is gone. The maintainer wants the trailing "i" + * affordance removed across the insights surface; the descriptive text it + * used to gate now reads inline as a muted caption, so the explanation is + * always visible rather than disclosure-only. The `label` prop is retained + * for callers but is no longer rendered as an accessible trigger name — + * there is no trigger to name. */ export function MoodExplainerIcon({ - label, detail, className, }: { - /** Accessible name for the trigger (what the icon explains). */ + /** Accessible name for the (now removed) trigger. Retained for callers. */ label: string; - /** The explanation shown in the tooltip. */ + /** The explanation, rendered inline as a muted caption. */ detail: string; className?: string; }) { return ( - - - - - - {detail} - - - + + {detail} + ); } diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index dd2ccce1d..b7a8615d3 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -2,18 +2,13 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CalendarRange, Info } from "lucide-react"; +import { CalendarRange } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/insights/section-heading"; import { AskCoachAction } from "@/components/insights/ask-coach-action"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { apiGet } from "@/lib/api/api-fetch"; /** @@ -64,6 +59,13 @@ async function fetchNarrative( return apiGet(`/api/insights/narrative?period=${period}`); } +/** + * v1.22 — the round ⓘ provenance glyph that hid the "how this was computed" + * read behind a popover is removed; the maintainer wants the trailing info + * affordance gone across the insights surface. The method line — and, when + * present, the metrics it drew on — now reads inline as a muted caption at the + * foot of the card, always visible rather than disclosure-only. + */ function ProvenanceDisclosure({ provenance, }: { @@ -72,27 +74,15 @@ function ProvenanceDisclosure({ const { t } = useTranslations(); const metrics = provenance.metrics.slice(0, 8).join(", "); return ( - - - - -

      - {t("insights.narrativeProvenanceLabel")} -

      -

      - {t("insights.narrativeProvenanceMethod")} -

      - {metrics ? ( -

      - {t("insights.narrativeProvenanceMetrics", { metrics })} -

      - ) : null} -
      -
      +

      + {t("insights.narrativeProvenanceMethod")} + {metrics + ? ` ${t("insights.narrativeProvenanceMetrics", { metrics })}` + : ""} +

      ); } @@ -168,11 +158,6 @@ export function PeriodNarrativeCard({ - ) : undefined - } />
      )} + {narrative?.provenance ? ( + + ) : null} + {narrative ? (

      {data?.revalidating diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 31486d280..875099824 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -3,16 +3,10 @@ import { useEffect, useRef, type ReactNode } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Info, ListOrdered } from "lucide-react"; +import { ListOrdered } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; import { TargetAdjustButton } from "@/components/insights/target-adjust-button"; import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context"; import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope"; @@ -344,33 +338,6 @@ export function SubPageShell({ className="text-muted-foreground text-sm leading-relaxed" > {t(`insights.subPage.explainer.${explainerMetric}Body`)} - {/* v1.18.6 — a discreet general-guidance tooltip travels with - every metric definition. The reference bands in the body - above are population anchors, not medical advice; this - caption pins that framing without crowding the prose. */} - - - - - - - {t("insights.subPage.explainer.referenceGuidance")} - - -

      ) : null} {description && !explainerMetric ? ( From 07b65ade8865a9d6e3584fd43cdcd984e323dfdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:02:41 +0200 Subject: [PATCH 10/32] Add a subtitle slot to the insights overview section heading Detail pages already render a one-line description under their heading; the overview section headings had no slot for one. Add an optional subtitle to SectionHeading, rendered in the same muted typography, and wire it into the obvious top-level sections: Trends (folding its existing right-aligned caption into the slot), Your health scores, and Your vitals. --- messages/de.json | 2 ++ messages/en.json | 2 ++ .../insights/derived/vitals-dashboard.tsx | 2 ++ .../insights/derived/wellness-scores.tsx | 2 ++ src/components/insights/section-heading.tsx | 25 ++++++++++++++++--- src/components/insights/trends-row.tsx | 6 +---- 6 files changed, 31 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index c07f8cf1f..e3e68d06a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3240,6 +3240,7 @@ }, "vitals": { "sectionTitle": "Deine Vitalwerte", + "sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline", "loadingLabel": "Vitalwerte werden geladen", "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", "building": "Typischer Bereich entsteht — {count} von {target} Tagen", @@ -3366,6 +3367,7 @@ }, "scores": { "sectionTitle": "Deine Gesundheitswerte", + "sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "recovery": "Erholung", "stress": "Stress", diff --git a/messages/en.json b/messages/en.json index 4e8e9c6f5..44106a387 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3240,6 +3240,7 @@ }, "vitals": { "sectionTitle": "Your vitals", + "sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline", "loadingLabel": "Loading your vitals", "loadError": "Could not load your vitals right now.", "building": "Building your typical range — {count} of {target} days", @@ -3366,6 +3367,7 @@ }, "scores": { "sectionTitle": "Your health scores", + "sectionSubtitle": "Composite recovery, stress and strain read from your connected devices", "loadError": "Could not load your wellness scores right now.", "recovery": "Recovery", "stress": "Stress", diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 00e400389..76f0c1394 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -729,6 +729,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
      ; /** Heading text rendered inside the `

      `. */ title: ReactNode; + /** + * Optional one-line description rendered directly under the `

      `, in the + * same muted typography the detail pages use beneath their headings + * (`text-muted-foreground text-sm`). Opt-in per section so the overview can + * carry the "descriptive line under every heading" rule without forcing a + * subtitle where none reads well. + */ + subtitle?: ReactNode; /** Optional trailing affordance pinned to the right edge of the row. */ action?: ReactNode; /** Optional id on the `

      `, e.g. for an `aria-labelledby` link. */ @@ -44,6 +52,7 @@ interface SectionHeadingProps { export function SectionHeading({ icon: Icon, title, + subtitle, action, id, className, @@ -58,9 +67,19 @@ export function SectionHeading({ >
      {action ? (
      {action}
      diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 5bc1c51b3..4056c052e 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -200,11 +200,7 @@ export function TrendsRow({ - {t("insights.trendsRow.subtitle")} -

      - } + subtitle={t("insights.trendsRow.subtitle")} /> {/* v1.4.22 A4 — equal-height cards. The annotation prose below each chart varies in length, which used to leave the From 7861c9b64217a81708e14a305f7cf1694b058d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:02:48 +0200 Subject: [PATCH 11/32] Show the steps pill for layouts that predate its tile id resolveInsightsLayout merged any default tile a saved layout did not enumerate as hidden, so a late-added tile such as steps (introduced in v1.12) was force-hidden on every read of an existing or iOS-synced layout and its nav pill never appeared, even with step data present. Appended tiles now inherit the default visibility instead, matching the fresh-account default; the per-tile data floor still keeps an empty metric out of the nav. --- src/lib/__tests__/insights-layout.test.ts | 8 ++++++++ src/lib/insights-layout.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index c68033ce7..b637a385f 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -76,6 +76,14 @@ describe("resolveInsightsLayout — v1 → v2 forward upgrade", () => { visible: false, order: 1, }); + + // v1.22 — a late-added default tile the saved blob never enumerated + // (e.g. `steps`, which entered the set in v1.12) merges in + // default-VISIBLE, matching the fresh-account default. The old + // force-hidden merge silently dropped its nav pill on every read of an + // existing or iOS-synced layout. + const steps = resolved.tiles.find((t) => t.id === "steps"); + expect(steps?.visible).toBe(true); }); it("returns the full default layout for garbage input", () => { diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 44cba5e6f..7b2d3367a 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -215,7 +215,14 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout { }); // Merge with defaults so new tiles introduced in later versions show - // up automatically (invisible by default, the user opts in). + // up automatically. v1.22 — appended tiles inherit the default's + // visibility (`visible: true` for every default tile) instead of being + // force-hidden. The old default-invisible override silently dropped the + // pill for any late-added tile id (e.g. `steps`, which entered the set in + // v1.12) on every read of an existing or iOS-synced layout that didn't + // enumerate it, so it never matched the fresh-account default. The + // per-tile data floor (`summaries[...].count > 0` at the call site) still + // keeps an empty metric from bloating the nav. const savedIds = new Set(filtered.map((t) => t.id)); const missing = DEFAULT_INSIGHTS_LAYOUT.tiles.filter( (t) => !savedIds.has(t.id), @@ -223,7 +230,6 @@ export function resolveInsightsLayout(raw: unknown): InsightsLayout { const maxOrder = Math.max(0, ...filtered.map((t) => t.order)); const appended = missing.map((t, i) => ({ ...t, - visible: false, // default-invisible on auto-upgrade order: maxOrder + 1 + i, })); From 8a7a2bc2e84a65f32f7d6b6b45cfc4ba1feb5fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:03:09 +0200 Subject: [PATCH 12/32] Link a measurement type to its insights sub-page Clicking a type on the measurements list now drills into that metric's insights sub-page when one exists, falling back to the existing ?type= filtered list when it does not. A reverse type-to-slug map backs the navigation, preferring the dedicated single-metric page so an ambiguous type resolves correctly (PULSE to pulse, not blood-pressure; WEIGHT to weight, not bmi). No API change. --- messages/de.json | 1 + messages/en.json | 1 + .../measurements/measurement-list.tsx | 54 ++++++++++++++++--- .../__tests__/sub-page-metric-reverse.test.ts | 53 ++++++++++++++++++ src/lib/insights/sub-page-metric.ts | 46 ++++++++++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 src/lib/insights/__tests__/sub-page-metric-reverse.test.ts diff --git a/messages/de.json b/messages/de.json index e3e68d06a..5493833e1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -511,6 +511,7 @@ "previousPage": "Vorherige Seite", "nextPage": "Nächste Seite", "dailyTotalCaption": "({count} Einzelwerte)", + "openMetricInsights": "{type}-Auswertung öffnen", "sleepNapCaption": "+{count} Nickerchen", "sleepAwakeningsCaption": "{count} Wachphasen", "expandDay": "Einzelwerte dieses Tages anzeigen", diff --git a/messages/en.json b/messages/en.json index 44106a387..a74a55a31 100644 --- a/messages/en.json +++ b/messages/en.json @@ -511,6 +511,7 @@ "previousPage": "Previous page", "nextPage": "Next page", "dailyTotalCaption": "({count} samples)", + "openMetricInsights": "Open {type} insights", "sleepNapCaption": "+{count} nap(s)", "sleepAwakeningsCaption": "{count} awakening(s)", "expandDay": "Show samples for this day", diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index b270a1fd4..aa6c3bf59 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; @@ -90,6 +91,10 @@ import { apiPost, apiPut, } from "@/lib/api/api-fetch"; +import { + INSIGHTS_OVERVIEW_PATH, + subPageSlugForType, +} from "@/lib/insights/sub-page-metric"; /** * v1.4.37 W7c — cumulative HK types whose list view collapses to one @@ -277,6 +282,20 @@ export function MeasurementList({ const fmt = useFormatters(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); + const router = useRouter(); + // v1.22 — clicking a row's type badge drills into that metric's Insights + // sub-page when one exists, else into the filtered measurements list. The + // reverse map prefers the dedicated single-metric slug (PULSE → `pulse`, + // WEIGHT → `weight`); types with no sub-page fall back to the `?type=` deep + // link the page already supports. + const navigateForType = (type: string) => { + const slug = subPageSlugForType(type); + router.push( + slug + ? `${INSIGHTS_OVERVIEW_PATH}/${slug}` + : `/measurements?type=${encodeURIComponent(type)}`, + ); + }; // v1.8.5 — when `lockedType` is set the list is pinned to that metric // and the type selector is hidden; the filter state seeds from it. // v1.18.7 (Wave E) — `initialType` (from a `?type=` deep link) seeds the @@ -922,12 +941,26 @@ export function MeasurementList({ - {TYPE_LABEL_KEYS[m.type] - ? t(TYPE_LABEL_KEYS[m.type]) - : m.type} + @@ -1111,11 +1144,18 @@ export function MeasurementList({

      )} {Icon && ( -
      navigateForType(m.type)} + aria-label={t("measurements.openMetricInsights", { + type: TYPE_LABEL_KEYS[m.type] + ? t(TYPE_LABEL_KEYS[m.type]) + : m.type, + })} + className={`focus-visible:ring-ring/50 flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg transition-colors focus-visible:ring-2 focus-visible:outline-none ${TYPE_COLORS[m.type] ?? ""}`} > -
      + )}
      {/* v1.4.27 MB7 / CF-76 — bump the metadata diff --git a/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts new file mode 100644 index 000000000..ffaa085f0 --- /dev/null +++ b/src/lib/insights/__tests__/sub-page-metric-reverse.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; + +import { + TYPE_TO_SUB_PAGE_SLUG, + subPageSlugForType, +} from "@/lib/insights/sub-page-metric"; + +/** + * v1.22 — the measurements list lets a user click a metric type to drill + * into its Insights sub-page. The reverse map underneath must resolve an + * ambiguous type to its dedicated single-metric page, never a multi-metric + * cluster page, and must stay silent for types with no card so the caller + * falls back to the filtered list. + */ +describe("type → insights sub-page slug", () => { + it("prefers the dedicated single-metric page over a multi-metric one", () => { + // PULSE lives on both `pulse` (=[PULSE]) and `blood-pressure` + // (=[SYS, DIA, PULSE]) — the single-metric page wins. + expect(subPageSlugForType("PULSE")).toBe("pulse"); + // WEIGHT lives on both `weight` (=[WEIGHT]) and the derived `bmi` + // (=[WEIGHT]) — the first single-metric page in slug order wins. + expect(subPageSlugForType("WEIGHT")).toBe("weight"); + }); + + it("falls back to the multi-metric page for its component types", () => { + expect(subPageSlugForType("BLOOD_PRESSURE_SYS")).toBe("blood-pressure"); + expect(subPageSlugForType("BLOOD_PRESSURE_DIA")).toBe("blood-pressure"); + // Both HRV flavours only appear on the `hrv` page. + expect(subPageSlugForType("HEART_RATE_VARIABILITY")).toBe("hrv"); + expect(subPageSlugForType("HRV_RMSSD")).toBe("hrv"); + }); + + it("maps a plain single-metric type to its page", () => { + expect(subPageSlugForType("ACTIVITY_STEPS")).toBe("steps"); + expect(subPageSlugForType("MOOD")).toBe("mood"); + expect(subPageSlugForType("BLOOD_GLUCOSE")).toBe("blood-glucose"); + }); + + it("returns undefined for a type with no sub-page", () => { + expect(subPageSlugForType("BODY_FAT")).toBeUndefined(); + expect(subPageSlugForType("RECOVERY_SCORE")).toBeUndefined(); + expect(subPageSlugForType("NOT_A_REAL_TYPE")).toBeUndefined(); + }); + + it("never resolves a type to an empty-metric (event-driven) page", () => { + // `workouts` and `medications` carry no measurement series, so no type + // should ever resolve to them. + for (const slug of Object.values(TYPE_TO_SUB_PAGE_SLUG)) { + expect(slug).not.toBe("workouts"); + expect(slug).not.toBe("medications"); + } + }); +}); diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 8c6421aa9..73967b7da 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -321,6 +321,52 @@ export const SUB_PAGE_MANAGER_GROUP_SLUGS: Record< ]), ) as unknown as Record; +/** + * v1.22 — reverse lookup: a measurement type → the routed insights sub-page + * slug that focuses it, when one exists. + * + * Built from {@link SUB_PAGE_METRIC}, preferring the slug whose metric list + * is exactly `[type]` so an ambiguous type resolves to its dedicated + * single-metric page rather than a multi-metric cluster: `PULSE` → `pulse` + * (not `blood-pressure`), `WEIGHT` → `weight` (not the derived `bmi`). When + * two single-metric pages list the same type (`weight` + `bmi` both list + * `WEIGHT`) the first in `SUB_PAGE_SLUGS` order wins (`weight`). A type that + * only ever appears on a multi-metric page (the two blood-pressure + * components, the two HRV flavours) falls back to that page. Types with no + * sub-page at all (BODY_FAT, RECOVERY_SCORE, the `*_EVENT` series, most + * device scores) are absent — the caller falls back to the filtered + * measurements list. + */ +export const TYPE_TO_SUB_PAGE_SLUG: Readonly> = + (() => { + const map: Record = {}; + // Pass 1 — exact single-metric pages win (first occurrence in slug order). + for (const slug of SUB_PAGE_SLUGS) { + const metrics = SUB_PAGE_METRIC[slug]; + if (metrics.length !== 1) continue; + const type = metrics[0]; + if (!(type in map)) map[type] = slug; + } + // Pass 2 — multi-metric pages fill any type a single-metric page misses. + for (const slug of SUB_PAGE_SLUGS) { + const metrics = SUB_PAGE_METRIC[slug]; + if (metrics.length === 1) continue; + for (const type of metrics) { + if (!(type in map)) map[type] = slug; + } + } + return map; + })(); + +/** + * The insights sub-page slug for a measurement type, or `undefined` when no + * routed card focuses it. See {@link TYPE_TO_SUB_PAGE_SLUG} for the + * ambiguity-resolution rule. + */ +export function subPageSlugForType(type: string): SubPageSlug | undefined { + return TYPE_TO_SUB_PAGE_SLUG[type]; +} + /** * Mother-page route. Kept here as a named constant so call sites * never have to hard-code the string. From 16ab522a065778edd9236602c1c007cb33fd9282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 13:31:03 +0200 Subject: [PATCH 13/32] feat(coach): render AI prose as real paragraphs with clickable Learn links Add a shared, pure ProseBlocks helper that splits trusted plain text on blank lines into

      blocks and single newlines into
      , with no markdown library and no dangerouslySetInnerHTML. A catalog-whitelisted linkifier turns grounded /learn/ references into safe anchors via learn-more-link; unknown slugs stay plain text. Rewrite StreamedProse to be streaming-safe: completed paragraphs settle while only the growing tail animates word by word. --- .../insights/__tests__/prose-blocks.test.tsx | 130 +++++++++++++ .../insights/coach-panel/streamed-prose.tsx | 115 ++++++++---- src/components/insights/prose-blocks.tsx | 175 ++++++++++++++++++ src/components/ui/learn-more-link.tsx | 50 ++++- 4 files changed, 431 insertions(+), 39 deletions(-) create mode 100644 src/components/insights/__tests__/prose-blocks.test.tsx create mode 100644 src/components/insights/prose-blocks.tsx diff --git a/src/components/insights/__tests__/prose-blocks.test.tsx b/src/components/insights/__tests__/prose-blocks.test.tsx new file mode 100644 index 000000000..65c20860d --- /dev/null +++ b/src/components/insights/__tests__/prose-blocks.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + ProseBlocks, + splitParagraphs, +} from "@/components/insights/prose-blocks"; +import { + StreamedProse, + splitProseSegments, +} from "@/components/insights/coach-panel/streamed-prose"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("splitParagraphs", () => { + it("splits on blank lines and trims, dropping empties", () => { + expect(splitParagraphs("one\n\ntwo")).toEqual(["one", "two"]); + expect(splitParagraphs(" a \n\n\n b ")).toEqual(["a", "b"]); + expect(splitParagraphs("solo")).toEqual(["solo"]); + expect(splitParagraphs("\n\n \n\n")).toEqual([]); + }); + + it("keeps a single newline inside one paragraph", () => { + expect(splitParagraphs("line1\nline2")).toEqual(["line1\nline2"]); + }); +}); + +describe("ProseBlocks", () => { + it("renders blank-line-separated text as real

      blocks", () => { + const html = render(); + const paras = (html.match(/", () => { + const html = render(); + expect(html).toContain("
      "); + expect((html.match(/ { + const html = render( + , + ); + expect(html).not.toContain("metric:WEIGHT"); + expect(html).toContain("Your weight"); + }); + + it("linkifies a catalog-known /learn/ reference as a safe anchor", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="inline-learn-link"'); + expect(html).toContain("/learn/resting-heart-rate"); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noopener noreferrer"'); + }); + + it("leaves an unknown /learn slug as plain text (fail-closed)", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="inline-learn-link"'); + expect(html).toContain("/learn/totally-made-up"); + }); + + it("does not strip or linkify when both are disabled (user text)", () => { + const html = render( + , + ); + expect(html).toContain("metric:WEIGHT"); + expect(html).not.toContain('data-slot="inline-learn-link"'); + }); +}); + +describe("StreamedProse", () => { + it("settles to real paragraph blocks when not streaming", () => { + const html = render( + , + ); + expect((html.match(/ { + const html = render( + , + ); + // The completed first paragraph settles as plain text (no spans). + expect(html).toContain("Settled paragraph."); + // The tail renders each word in its own fade-in span. + for (const word of ["Growing ", "tail ", "words"]) { + expect(html).toContain(`>${word}`); + } + }); + + it("does not leak a chart token into streamed prose", () => { + const html = render( + , + ); + expect(html).not.toContain("metric:WEIGHT"); + }); +}); + +describe("splitProseSegments", () => { + it("keeps each word with its trailing whitespace", () => { + expect(splitProseSegments("a b c")).toEqual(["a ", "b ", "c"]); + expect(splitProseSegments("")).toEqual([]); + }); +}); diff --git a/src/components/insights/coach-panel/streamed-prose.tsx b/src/components/insights/coach-panel/streamed-prose.tsx index 370d70a43..7b41f4df0 100644 --- a/src/components/insights/coach-panel/streamed-prose.tsx +++ b/src/components/insights/coach-panel/streamed-prose.tsx @@ -4,22 +4,37 @@ import { useMemo } from "react"; import { cn } from "@/lib/utils"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { + ProseBlocks, + ParagraphText, + splitParagraphs, + PROSE_PARAGRAPH_CLASS, +} from "@/components/insights/prose-blocks"; /** * v1.18.9 — Claude-style word-by-word fade-in for the LIVE Coach turn. * * The provider clients return the whole reply in one shot; the chat route * re-chunks it into per-word `token` SSE frames (`tokeniseForStreaming`), - * so `content` grows as frames land. This component renders each - * whitespace segment as its own `` that fades + drifts up on mount, - * giving the reply a "thinking out loud" cadence rather than a hard dump - * of word groups. Once the turn settles (`streaming === false`) the spans - * collapse to a single plain-text node — zero residual animation DOM, and - * the feedback / evidence layout underneath stays stable. + * so `content` grows as frames land. While the turn is streaming each + * whitespace segment of the GROWING tail renders as its own `` that + * fades + drifts up on mount, giving the reply a "thinking out loud" + * cadence rather than a hard dump of word groups. * - * Why no "only animate the new words" bookkeeping: each segment is keyed - * by its index, so when a fresh SSE frame appends words, React keeps the - * already-mounted spans (their CSS entrance already ran and does not + * v1.22 (W5) — paragraph-aware + SSE-safe. The reply now renders as real + * `

      ` blocks (shared `ProseBlocks`), so a model that emits blank-line + * paragraph breaks reads as paragraphs, not one run-on block — consistent + * with the daily briefing + insight cards. Under streaming, every COMPLETE + * paragraph settles immediately (plain text, with the safe Learn linkifier) + * and only the LAST, still-growing paragraph animates word-by-word. When a + * fresh `\n\n` arrives the tail becomes a completed paragraph and stops + * animating; the already-settled paragraphs keep their index keys and never + * replay. Once the whole turn settles (`streaming === false`) it collapses + * to the same `ProseBlocks` render the persisted history uses. + * + * Why no "only animate the new words" bookkeeping: each tail segment is + * keyed by its index, so when a fresh SSE frame appends words React keeps + * the already-mounted spans (their CSS entrance already ran and does not * replay on reconcile) and only MOUNTS the new trailing spans — which is * exactly when the `animate-in` entrance fires. The browser does the * "new words only" gating for us; no render-time ref is needed. @@ -30,8 +45,8 @@ import { stripChartTokens } from "@/lib/insights/chart-tokens"; * attributes are already permitted under the strict CSP * (`style-src 'self' 'unsafe-inline'`); there is no runtime-injected * `