From e33911e2d440389a7dc32a6e1dc258be0125f9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:24:11 +0200 Subject: [PATCH 01/17] feat(insights): generic per-metric assessment coverage for HealthKit metrics Extend the assessment-card system beyond the seven specialised scopes to the ~30 HealthKit metric pages, without hand-writing a prompt per metric. - Add a metric-metadata registry keyed by HealthKit id (display name, unit, direction, optional normal range, archetype, scope), covering the data-bearing HealthKit metrics. The seven specialised metrics keep their own scopes and are excluded from the generic set; an unregistered id gets no card. - Add a generic data-driven generator that reuses the existing graded snapshot, provider runner, stale-while-revalidate cache, and persist path. One prompt template per archetype (physiological-vital, activity-fitness, body-composition, mobility-gait, environmental-exposure) plus a dedicated sleep template, parameterised by metric metadata. Empty-data metrics return an insufficient marker with no provider call. Normal ranges use age + sex where the profile stores them; no ancestry/region ranges. - Add GET /api/insights/metric-status?metric=: apiHandler-wrapped, Zod-validated against the closed registry id set (422 on unknown), read-only with stale-while-revalidate. First insights route with OpenAPI coverage; regenerate docs/api/openapi.yaml. - Route the generic metric: scope through the on-demand status queue worker dispatch. - Extend the nightly warm pass to the generic metric caches for a user's data-bearing metrics only (one distinct read gates the set), keeping de+en warm and never calling a provider for an empty metric. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 41453fbdfee60793c16269b9a9e5f04b6c3ea450) --- docs/api/openapi.yaml | 128 +++++ .../coach-route-gate-inventory.test.ts | 3 + .../metric-status/__tests__/route.test.ts | 127 +++++ src/app/api/insights/metric-status/route.ts | 86 ++++ src/lib/ai/prompts/metric-archetypes.ts | 145 ++++++ .../insights/__tests__/metric-status.test.ts | 218 +++++++++ src/lib/insights/metric-status-registry.ts | 451 ++++++++++++++++++ src/lib/insights/metric-status.ts | 349 ++++++++++++++ src/lib/insights/status-cache.ts | 4 +- .../__tests__/insight-pregenerate.test.ts | 56 +++ src/lib/jobs/insight-pregenerate.ts | 106 ++++ .../jobs/insight-status-generate-shared.ts | 19 +- src/lib/jobs/insight-status-generate.ts | 27 +- src/lib/openapi/routes.ts | 84 ++++ 14 files changed, 1799 insertions(+), 4 deletions(-) create mode 100644 src/app/api/insights/metric-status/__tests__/route.test.ts create mode 100644 src/app/api/insights/metric-status/route.ts create mode 100644 src/lib/ai/prompts/metric-archetypes.ts create mode 100644 src/lib/insights/__tests__/metric-status.test.ts create mode 100644 src/lib/insights/metric-status-registry.ts create mode 100644 src/lib/insights/metric-status.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cd0f537f7..dfd5188dd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1927,6 +1927,78 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/metric-status: + get: + tags: + - Insights + summary: Generic per-HealthKit-metric assessment + description: "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, + sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via + archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and + serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed + registry enum. Auth via cookie or Bearer." + parameters: + - in: query + name: metric + schema: + type: string + enum: + - RESTING_HEART_RATE + - HEART_RATE_VARIABILITY + - OXYGEN_SATURATION + - RESPIRATORY_RATE + - BODY_TEMPERATURE + - SKIN_TEMPERATURE + - BLOOD_GLUCOSE + - WALKING_HEART_RATE_AVERAGE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - STEPS + - ACTIVE_ENERGY + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - TIME_IN_DAYLIGHT + - VO2_MAX + - TOTAL_BODY_WATER + - BONE_MASS + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - LEAN_BODY_MASS + - VISCERAL_FAT + - WALKING_STEADINESS + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - AUDIO_EXPOSURE_EVENT + - SLEEP_DURATION + description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. + The seven specialised metrics are served by their own routes and are not accepted here." + required: true + description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. + The seven specialised metrics are served by their own routes and are not accepted here." + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/MetricStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 components: schemas: PasskeyLoginVerifyRequest: @@ -6171,6 +6243,62 @@ components: additionalProperties: false description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding. + MetricStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MetricStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MetricStatusResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing, or when the metric has insufficient data. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + insufficient: + description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its + insufficient-data state. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the + `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation + out of band and serves the last-good text meanwhile." MedicationScheduleOutput: type: object properties: diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 669e476c7..f64973294 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -40,6 +40,9 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ "src/app/api/insights/cards/route.ts", "src/app/api/insights/correlations/route.ts", "src/app/api/insights/medication-compliance-status/route.ts", + // v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the + // same `insightStatus` sub-flag as the seven specialised status routes. + "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts new file mode 100644 index 000000000..45626b4a1 --- /dev/null +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the +// real status pipeline. +vi.mock("@/lib/insights/metric-status", () => ({ + generateMetricStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { generateMetricStatus } from "@/lib/insights/metric-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(metric?: string): NextRequest { + const url = new URL("http://localhost/api/insights/metric-status"); + if (metric !== undefined) url.searchParams.set("metric", metric); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("GET /api/insights/metric-status", () => { + it("returns 200 + envelope for a valid registered metric", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("RESTING_HEART_RATE")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateMetricStatus).toHaveBeenCalledWith( + expect.objectContaining({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on an unknown metric without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("NOT_A_METRIC")); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("422s on a missing metric param", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("422s when a specialised metric is requested through the generic route", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("WEIGHT")); + expect(res.status).toBe(422); + expect(generateMetricStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("SLEEP_DURATION")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("SLEEP_DURATION")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts new file mode 100644 index 000000000..3887c1eca --- /dev/null +++ b/src/app/api/insights/metric-status/route.ts @@ -0,0 +1,86 @@ +/** + * v1.8.7.1 — generic per-HealthKit-metric assessment route. + * + * `GET /api/insights/metric-status?metric=` serves the + * data-driven assessment for any metric registered in + * `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …). + * The seven specialised metrics keep their own `*-status` routes; this + * single generic route covers the ~30 HealthKit pages without one route + * per metric. + * + * Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an + * out-of-band generation and serves the last-good (stale-while-revalidate) + * text, never blocking on the provider. An unknown `metric` 422s via the + * closed registry enum. + * + * Unlike the seven legacy `*-status` routes, this one carries OpenAPI + * coverage (the route schema lives in `src/lib/openapi/routes.ts`), + * establishing the pattern the legacy routes can be backfilled onto later. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { + generateMetricStatus, + resolveMetricStatusLocale, +} from "@/lib/insights/metric-status"; +import { + METRIC_STATUS_IDS, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; + +export const dynamic = "force-dynamic"; + +// Closed enum derived from the registry so the route + registry cannot +// drift — an id added to the registry is accepted automatically, and an +// unknown id 422s rather than silently resolving to no card. +const metricQuerySchema = z.object({ + metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const parsed = metricQuerySchema.safeParse({ + metric: request.nextUrl.searchParams.get("metric"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.metric-status.invalid-metric" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const metric = parsed.data.metric as MetricStatusMetricId; + + const localeParam = request.nextUrl.searchParams.get("locale"); + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: localeParam, + }); + const locale = resolveMetricStatusLocale(resolved); + + const result = await generateMetricStatus({ + metric, + userId: user.id, + locale, + force: false, + readOnly: true, + }); + + annotate({ + action: { name: "insights.metric-status" }, + meta: { metric }, + }); + + return apiSuccess(result); +}); diff --git a/src/lib/ai/prompts/metric-archetypes.ts b/src/lib/ai/prompts/metric-archetypes.ts new file mode 100644 index 000000000..ef026b622 --- /dev/null +++ b/src/lib/ai/prompts/metric-archetypes.ts @@ -0,0 +1,145 @@ +/** + * v1.8.7.1 — archetype prompt templates for the generic per-metric + * assessment generator. + * + * The seven specialised status cards each ship a bespoke + * `src/lib/ai/prompts/.ts`. Hand-writing ~30 more for the + * HealthKit metric pages is not viable, so this module parameterises one + * template per archetype (five) plus a dedicated `sleep` template. Each + * template is driven entirely by the metric metadata (display name, + * unit, direction, normal range) + the same graded snapshot shape the + * specialised cards send, so a new metric in the registry produces a + * grounded assessment without a new prompt. + * + * Every template extends `getBaseSystemPrompt` (the shared "one short, + * grounded, warm assessment paragraph → { summary }" contract) and adds + * a per-archetype section describing how to read THAT family of metrics + * (a vital is a point-in-time reading placed against a normal range; an + * activity metric is a cumulative daily count, mostly higher-better; a + * mobility metric is a stability signal; …). The user prompt then ships + * the snapshot JSON + the metric metadata + the optional previous-context + * block, exactly like the specialised generators. + */ +import type { Locale } from "@/lib/i18n/config"; +import { getBaseSystemPrompt } from "./base-system"; +import type { + MetricArchetype, + MetricStatusMeta, +} from "@/lib/insights/metric-status-registry"; + +function directionPhrase( + meta: MetricStatusMeta, + locale: Locale, +): string { + if (locale === "en") { + switch (meta.direction) { + case "higher-better": + return "Higher values are generally more favourable for this metric."; + case "lower-better": + return "Lower values are generally more favourable for this metric."; + case "target-band": + return "Neither extreme is good — the value should sit inside its normal band; deviations in either direction are worth noting."; + } + } + switch (meta.direction) { + case "higher-better": + return "Höhere Werte sind bei dieser Metrik tendenziell günstiger."; + case "lower-better": + return "Niedrigere Werte sind bei dieser Metrik tendenziell günstiger."; + case "target-band": + return "Kein Extrem ist gut — der Wert sollte im Normalband liegen; Abweichungen in beide Richtungen sind erwähnenswert."; + } +} + +/** + * Render the metric-metadata block both the system section and the user + * prompt reference. Kept terse — the snapshot carries the numbers, this + * just tells the model what it is looking at. + */ +function metaBlock(meta: MetricStatusMeta, locale: Locale): string { + const range = meta.normalRange + ? locale === "en" + ? `Coarse population reference band: ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (a placement aid only — the user's OWN baseline leads).` + : `Grobes Referenzband (Population): ${meta.normalRange.low}–${meta.normalRange.high} ${meta.unit} (nur Orientierung — die EIGENE Baseline führt).` + : locale === "en" + ? "No fixed population band applies — judge purely against the user's own baseline." + : "Kein festes Referenzband — beurteile rein gegen die eigene Baseline der Person."; + if (locale === "en") { + return [ + `METRIC: ${meta.displayName} (unit: ${meta.unit}).`, + directionPhrase(meta, locale), + range, + ].join("\n"); + } + return [ + `METRIK: ${meta.displayName} (Einheit: ${meta.unit}).`, + directionPhrase(meta, locale), + range, + ].join("\n"); +} + +const ARCHETYPE_SECTION: Record< + MetricArchetype, + { de: string; en: string } +> = { + "physiological-vital": { + en: "ARCHETYPE — PHYSIOLOGICAL VITAL:\n- This is a point-in-time physiological reading. Name the recent level, place it against both the user's own baseline (recent vs monthly/yearly mean) and, when a reference band is given, that band.\n- A single out-of-band reading is rarely a finding; a sustained shift across the recent window against the user's baseline is. Note an out-of-band trend without alarm.\n- Frame any direction (rising/falling) as a possible explanation (fitness, stress, sleep, illness, medication), never a diagnosis.", + de: "ARCHETYP — PHYSIOLOGISCHER VITALWERT:\n- Das ist ein Momentanwert. Benenne das aktuelle Niveau und ordne es sowohl gegen die eigene Baseline (recent vs. Monats-/Jahresmittel) als auch — falls ein Referenzband vorliegt — gegen dieses Band ein.\n- Ein einzelner Wert außerhalb des Bands ist selten ein Befund; eine anhaltende Verschiebung über das recent-Fenster gegen die Baseline schon. Erwähne einen Trend außerhalb des Bands sachlich, ohne zu alarmieren.\n- Formuliere jede Richtung (steigend/fallend) als mögliche Erklärung (Fitness, Stress, Schlaf, Krankheit, Medikament), nie als Diagnose.", + }, + "activity-fitness": { + en: "ARCHETYPE — ACTIVITY / FITNESS:\n- This is a daily activity or fitness measure, mostly higher-better. Read the recent daily mean and compare it to the user's own weekly/monthly baseline — is their activity up, down, or steady?\n- Day-to-day swings are expected; report the multi-day trend, not a single low or high day. When a reference band is given, use it as a coarse target, not a verdict.\n- Close with one doable, specific step toward more (or sustained) movement — never the generic 'exercise more' platitude.", + de: "ARCHETYP — AKTIVITÄT / FITNESS:\n- Das ist ein tägliches Aktivitäts- oder Fitnessmaß, meist höher = besser. Lies das recent-Tagesmittel und vergleiche es mit der eigenen Wochen-/Monats-Baseline — ist die Aktivität gestiegen, gesunken oder stabil?\n- Tagesschwankungen sind normal; melde den Mehrtagestrend, nicht einen einzelnen niedrigen oder hohen Tag. Ein Referenzband ist ein grobes Ziel, kein Urteil.\n- Schließe mit einem machbaren, konkreten Schritt für mehr (oder gleichbleibende) Bewegung — nie die generische Floskel 'mehr Bewegung'.", + }, + "body-composition": { + en: "ARCHETYPE — BODY COMPOSITION:\n- This is a body-composition mass (or rating). A healthy absolute value depends entirely on body size, so judge the TREND toward or away from the user's own baseline, not an absolute number.\n- Report a sustained directional change across weeks/months; single readings carry scale + hydration noise. Tie it to weight context only if the snapshot supports it.\n- Close with one step that supports the favourable direction for THIS metric.", + de: "ARCHETYP — KÖRPERZUSAMMENSETZUNG:\n- Das ist eine Körperzusammensetzungs-Masse (oder Bewertung). Ein gesunder Absolutwert hängt stark von der Körpergröße ab — beurteile den TREND zur bzw. weg von der eigenen Baseline, nicht eine Absolutzahl.\n- Melde eine anhaltende Richtungsänderung über Wochen/Monate; Einzelwerte tragen Waagen- und Hydrationsrauschen. Stelle einen Gewichtsbezug nur her, wenn der Snapshot ihn stützt.\n- Schließe mit einem Schritt, der die für DIESE Metrik günstige Richtung unterstützt.", + }, + "mobility-gait": { + en: "ARCHETYPE — MOBILITY / GAIT:\n- This is a gait/mobility-stability signal Apple surfaces in its Mobility section. Its value is as a stability + anomaly flag: a steady reading near the user's baseline is reassuring; a sustained drift in the unfavourable direction is the signal to surface.\n- These metrics move slowly; one off day is noise. Only flag a multi-week drift, and frame it as a mobility observation, never a fall-risk diagnosis.\n- If steady, say so plainly. If drifting unfavourably, give one gentle, doable step.", + de: "ARCHETYP — MOBILITÄT / GANG:\n- Das ist ein Gang-/Mobilitäts-Stabilitätssignal aus Apples Mobilitätsbereich. Sein Wert liegt als Stabilitäts- und Anomalie-Flag: ein stabiler Wert nahe der Baseline beruhigt; eine anhaltende Drift in die ungünstige Richtung ist das Signal.\n- Diese Werte ändern sich langsam; ein Ausreißertag ist Rauschen. Melde nur eine mehrwöchige Drift und formuliere sie als Mobilitätsbeobachtung, nie als Sturzrisiko-Diagnose.\n- Ist es stabil, sage das klar. Driftet es ungünstig, gib einen sanften, machbaren Schritt.", + }, + "environmental-exposure": { + en: "ARCHETYPE — ENVIRONMENTAL EXPOSURE:\n- This is an environmental exposure (sound level / loud-event count), lower-better. The reference band is a threshold (e.g. WHO 80 dBA) — recent values above it sustained across days are the finding worth surfacing.\n- A single loud concert or flight is not a pattern. Report repeated above-threshold exposure, not an isolated spike.\n- Close with one practical step to reduce exposure (e.g. lower the listening volume, take a quiet break) — framed as protective, never alarming.", + de: "ARCHETYP — UMWELT-EXPOSITION:\n- Das ist eine Umwelt-Exposition (Schallpegel / Anzahl lauter Ereignisse), niedriger = besser. Das Referenzband ist ein Schwellenwert (z.B. WHO 80 dBA) — recent-Werte, die ihn über mehrere Tage überschreiten, sind der erwähnenswerte Befund.\n- Ein einzelnes lautes Konzert oder ein Flug ist kein Muster. Melde wiederholte Überschreitungen, nicht einen Einzelausschlag.\n- Schließe mit einem praktischen Schritt zur Reduktion (z.B. Hörlautstärke senken, eine ruhige Pause) — schützend formuliert, nie alarmierend.", + }, + sleep: { + en: "ARCHETYPE — SLEEP:\n- This is nightly sleep duration. The snapshot's values are in minutes; convert to hours for your prose (e.g. 450 min = 7.5 h). Assess both the typical duration and its CONSISTENCY: a stable ~7–9 h is the favourable pattern; high night-to-night variability is itself worth naming even when the mean looks fine.\n- Compare the recent nights to the user's own weekly/monthly baseline. A reference band of 7–9 h is a coarse adult guide, not a verdict — short or long sleepers vary.\n- Close with one doable step toward more consistent or sufficient sleep — never the forbidden 'get enough sleep' platitude; be specific (e.g. a steadier wind-down time).", + de: "ARCHETYP — SCHLAF:\n- Das ist die nächtliche Schlafdauer. Die Snapshot-Werte sind in Minuten; rechne für deinen Text in Stunden um (z.B. 450 min = 7,5 h). Bewerte sowohl die typische Dauer als auch ihre KONSTANZ: stabile ~7–9 h sind das günstige Muster; hohe Schwankung von Nacht zu Nacht ist selbst dann erwähnenswert, wenn der Mittelwert passt.\n- Vergleiche die jüngsten Nächte mit der eigenen Wochen-/Monats-Baseline. Ein Referenzband von 7–9 h ist eine grobe Erwachsenen-Orientierung, kein Urteil — Kurz- und Langschläfer variieren.\n- Schließe mit einem machbaren Schritt zu konstanterem oder ausreichenderem Schlaf — nie die verbotene Floskel 'achte auf ausreichend Schlaf'; sei konkret (z.B. eine festere Einschlafzeit).", + }, +}; + +export function getMetricArchetypeSystemPrompt( + meta: MetricStatusMeta, + locale: Locale, +): string { + const section = ARCHETYPE_SECTION[meta.archetype]; + const archetypeText = locale === "en" ? section.en : section.de; + return `${getBaseSystemPrompt(locale)} + +${archetypeText} + +${metaBlock(meta, locale)}`; +} + +export function getMetricArchetypeUserPrompt( + meta: MetricStatusMeta, + snapshotJson: string, + todayKey: string, + locale: Locale, + previousContextBlock?: string, +): string { + const ctxBlock = + previousContextBlock && previousContextBlock.trim().length > 0 + ? `\n\n${previousContextBlock}\n` + : ""; + if (locale === "en") { + return `Date: ${todayKey} (Europe/Berlin) +Write one short assessment of this person's ${meta.displayName.toLowerCase()}: name the current level with a concrete number from the snapshot, place the recent window against their own weekly/monthly baseline, and close with one doable step. Judge confidence from the measurement count and recency.${ctxBlock} + +${snapshotJson}`; + } + return `Datum: ${todayKey} (Europe/Berlin) +Schreibe eine kurze Einschätzung zu ${meta.displayName} dieser Person: benenne das aktuelle Niveau mit einer konkreten Zahl aus dem Snapshot, ordne das recent-Fenster gegen die eigene Wochen-/Monats-Baseline ein und schließe mit einem machbaren Schritt. Konfidenz aus Messanzahl und Aktualität ableiten.${ctxBlock} + +${snapshotJson}`; +} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts new file mode 100644 index 000000000..db98c42ec --- /dev/null +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn(), count: vi.fn() }, + measurementRollup: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), +})); + +vi.mock("@/lib/insights/memory", () => ({ + getPreviousInsightContext: vi.fn().mockResolvedValue(null), + formatPreviousContextForPrompt: vi.fn().mockReturnValue(""), +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { generateMetricStatus } from "../metric-status"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { + getMetricStatusMeta, + metricStatusScope, + METRIC_STATUS_IDS, +} from "../metric-status-registry"; + +const dayMs = 24 * 60 * 60 * 1000; + +function stubCompletion( + content: string, + capture?: { systemPrompt: string | null; userPrompt: string | null }, +) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { systemPrompt: string; userPrompt: string }) => { + if (capture) { + capture.systemPrompt = args.systemPrompt; + capture.userPrompt = args.userPrompt; + } + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never); +}); + +describe("metric-status registry", () => { + it("excludes the seven specialised metrics from the generic set", () => { + for (const excluded of [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BMI", + "MOOD", + "MEDICATION", + ]) { + expect(METRIC_STATUS_IDS).not.toContain(excluded); + } + }); + + it("scope id carries the metric: prefix and (with -status suffix) keeps the eviction substring", () => { + expect(metricStatusScope("RESTING_HEART_RATE")).toBe( + "metric:RESTING_HEART_RATE", + ); + // The cache action appends `-status.`; the eviction sweep + // matches on the `-status.` substring, so a generic scope is swept too. + expect( + `insights.${metricStatusScope("SLEEP_DURATION")}-status.de`, + ).toContain("-status."); + }); + + it("maps STEPS / ACTIVE_ENERGY ids onto their divergent MeasurementType", () => { + expect(getMetricStatusMeta("STEPS")?.measurementType).toBe( + "ACTIVITY_STEPS", + ); + expect(getMetricStatusMeta("ACTIVE_ENERGY")?.measurementType).toBe( + "ACTIVE_ENERGY_BURNED", + ); + }); +}); + +describe("archetype prompt templates", () => { + it("injects the metric metadata + normal range into the system prompt", () => { + const meta = getMetricStatusMeta("OXYGEN_SATURATION")!; + const sys = getMetricArchetypeSystemPrompt(meta, "en"); + expect(sys).toContain("Blood oxygen"); + expect(sys).toContain("95"); + expect(sys).toContain("PHYSIOLOGICAL VITAL"); + }); + + it("uses the dedicated sleep archetype for SLEEP_DURATION", () => { + const meta = getMetricStatusMeta("SLEEP_DURATION")!; + const sys = getMetricArchetypeSystemPrompt(meta, "en"); + expect(sys).toContain("SLEEP"); + expect(getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en")).toContain( + "sleep duration", + ); + }); +}); + +describe("generateMetricStatus — empty-data guard", () => { + it("returns insufficient WITHOUT calling the provider when the metric has no data", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(0 as never); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + // No raw read either — the guard short-circuits before the gather. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("generateMetricStatus — generation path", () => { + it("builds a graded snapshot, runs the archetype completion, and persists", async () => { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < 400; day++) { + records.push({ + value: 55 + (day % 6), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(400 as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Your resting heart rate is steady."}', captured); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Your resting heart rate is steady."); + expect(result.cached).toBe(false); + expect(result.hasProvider).toBe(true); + + // The snapshot embeds the graded series, not a raw daily array. + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + expect(snapshot.RESTING_HEART_RATE.series).toHaveProperty("recent"); + expect(snapshot.metric.unit).toBe("bpm"); + + // Persisted under the generic scope cache action. + const createCall = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { action: string; details: string }; + }; + expect(createCall.data.action).toBe( + "insights.metric:RESTING_HEART_RATE-status.en", + ); + }); + + it("strips chart tokens from the persisted text", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.count).mockResolvedValue(3 as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 58, measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + stubCompletion( + '{"summary":"Steady. metric:RESTING_HEART_RATE Good baseline."}', + ); + + const result = await generateMetricStatus({ + metric: "RESTING_HEART_RATE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).not.toContain("metric:"); + expect(result.text).toContain("Steady."); + }); +}); diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts new file mode 100644 index 000000000..122648448 --- /dev/null +++ b/src/lib/insights/metric-status-registry.ts @@ -0,0 +1,451 @@ +/** + * v1.8.7.1 — generic per-metric assessment registry. + * + * The seven specialised assessment scopes (blood-pressure / pulse / + * weight / bmi / mood / medication-compliance / general) each ship a + * bespoke generator + prompt. The ~30 HealthKit metric pages + * (`HealthKitMetricPage`) carried charts but no plain-language + * assessment, because hand-writing 30 more prompts is not viable. + * + * This registry is the data-driven alternative: each HealthKit metric is + * described by the small amount of metadata a generic generator needs to + * assess it — a display name, a unit, the direction a "good" value moves, + * an optional clinical/consumer normal range, and an archetype that + * selects one of six shared prompt templates. A metric with no entry here + * simply gets no assessment card (the safe default the spec calls for). + * + * Metric ids are the existing `InsightMetric` HealthKit identifiers + * (WEIGHT, RESTING_HEART_RATE, SLEEP_DURATION, …) so the UI contract, + * the route query param, and the cache scope all speak one vocabulary. + * Internally each id maps to the `MeasurementType` the rollup tier and + * the raw `Measurement` rows are keyed by (a handful differ — `STEPS` + * is stored as `ACTIVITY_STEPS`, `ACTIVE_ENERGY` as `ACTIVE_ENERGY_BURNED`). + * + * The seven specialised metrics (WEIGHT, BLOOD_PRESSURE_*, PULSE, BMI, + * MOOD, MEDICATION) are intentionally NOT registered here — they keep + * their existing dedicated scopes and are never routed through the + * generic path. + * + * Medical note (design §"Medical note"): normal ranges are population + * anchors a generic consumer/clinical guideline supports, used only as a + * coarse placement aid. The generator's primary signal is the user's OWN + * baseline (the graded series), and where the profile stores age + sex + * (`dateOfBirth` + `gender`) the generator threads them so the model can + * sanity-check the placement. NO ancestry/region-of-birth ranges — that + * is medically contentious and a data category HealthLog does not + * collect. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { InsightMetric } from "@/lib/insights/metric-availability"; + +/** The five shared archetypes plus the dedicated sleep template. */ +export type MetricArchetype = + | "physiological-vital" + | "activity-fitness" + | "body-composition" + | "mobility-gait" + | "environmental-exposure" + | "sleep"; + +/** + * Direction a favourable value moves. `target-band` means neither + * extreme is good — the value should sit inside `normalRange`. + */ +export type MetricDirection = "higher-better" | "lower-better" | "target-band"; + +export interface MetricNormalRange { + low: number; + high: number; +} + +export interface MetricStatusMeta { + /** The HealthKit metric id — the public route param + cache scope. */ + id: MetricStatusMetricId; + /** The DB `MeasurementType` the rollup tier + raw reads are keyed by. */ + measurementType: MeasurementType; + /** Stable English display name (the model localises its prose itself). */ + displayName: string; + /** Canonical storage unit (matches the DB column semantics). */ + unit: string; + direction: MetricDirection; + /** Coarse population placement anchor; the user's own baseline leads. */ + normalRange?: MetricNormalRange; + archetype: MetricArchetype; +} + +/** + * The metric ids the generic assessment path covers. A strict subset of + * `InsightMetric` — the seven specialised metrics and the event-driven + * MOOD / MEDICATION / WORKOUTS keys are excluded by construction. + */ +export type MetricStatusMetricId = + | "RESTING_HEART_RATE" + | "HEART_RATE_VARIABILITY" + | "OXYGEN_SATURATION" + | "RESPIRATORY_RATE" + | "BODY_TEMPERATURE" + | "SKIN_TEMPERATURE" + | "BLOOD_GLUCOSE" + | "WALKING_HEART_RATE_AVERAGE" + | "PULSE_WAVE_VELOCITY" + | "VASCULAR_AGE" + | "STEPS" + | "ACTIVE_ENERGY" + | "FLIGHTS_CLIMBED" + | "WALKING_RUNNING_DISTANCE" + | "TIME_IN_DAYLIGHT" + | "VO2_MAX" + | "TOTAL_BODY_WATER" + | "BONE_MASS" + | "FAT_FREE_MASS" + | "FAT_MASS" + | "MUSCLE_MASS" + | "LEAN_BODY_MASS" + | "VISCERAL_FAT" + | "WALKING_STEADINESS" + | "WALKING_ASYMMETRY" + | "WALKING_DOUBLE_SUPPORT" + | "WALKING_STEP_LENGTH" + | "WALKING_SPEED" + | "AUDIO_EXPOSURE_ENV" + | "AUDIO_EXPOSURE_HEADPHONE" + | "AUDIO_EXPOSURE_EVENT" + | "SLEEP_DURATION"; + +/** + * The registry. Keyed by the HealthKit metric id. Each entry's + * `measurementType` is the DB enum the rollup tier reads against. + * + * Normal ranges are coarse consumer/clinical anchors (resting-pulse + * 60–100 bpm, SpO2 95–100 %, …) — present only where a broadly-accepted + * placement exists. Body-composition mass metrics carry no fixed band: + * a healthy fat-free mass depends entirely on body size, so the + * `target-band` direction defers wholly to the user's own baseline. + */ +const REGISTRY: Record = { + // ── physiological-vital ── + RESTING_HEART_RATE: { + id: "RESTING_HEART_RATE", + measurementType: "RESTING_HEART_RATE", + displayName: "Resting heart rate", + unit: "bpm", + direction: "lower-better", + normalRange: { low: 50, high: 100 }, + archetype: "physiological-vital", + }, + HEART_RATE_VARIABILITY: { + id: "HEART_RATE_VARIABILITY", + measurementType: "HEART_RATE_VARIABILITY", + displayName: "Heart-rate variability (SDNN)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + }, + OXYGEN_SATURATION: { + id: "OXYGEN_SATURATION", + measurementType: "OXYGEN_SATURATION", + displayName: "Blood oxygen (SpO₂)", + unit: "%", + direction: "higher-better", + normalRange: { low: 95, high: 100 }, + archetype: "physiological-vital", + }, + RESPIRATORY_RATE: { + id: "RESPIRATORY_RATE", + measurementType: "RESPIRATORY_RATE", + displayName: "Respiratory rate", + unit: "breaths/min", + direction: "target-band", + normalRange: { low: 12, high: 20 }, + archetype: "physiological-vital", + }, + BODY_TEMPERATURE: { + id: "BODY_TEMPERATURE", + measurementType: "BODY_TEMPERATURE", + displayName: "Body temperature", + unit: "°C", + direction: "target-band", + normalRange: { low: 36.1, high: 37.5 }, + archetype: "physiological-vital", + }, + SKIN_TEMPERATURE: { + id: "SKIN_TEMPERATURE", + measurementType: "SKIN_TEMPERATURE", + displayName: "Wrist skin temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + }, + BLOOD_GLUCOSE: { + id: "BLOOD_GLUCOSE", + measurementType: "BLOOD_GLUCOSE", + displayName: "Blood glucose", + unit: "mg/dL", + direction: "target-band", + normalRange: { low: 70, high: 140 }, + archetype: "physiological-vital", + }, + WALKING_HEART_RATE_AVERAGE: { + id: "WALKING_HEART_RATE_AVERAGE", + measurementType: "WALKING_HEART_RATE_AVERAGE", + displayName: "Walking heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + }, + PULSE_WAVE_VELOCITY: { + id: "PULSE_WAVE_VELOCITY", + measurementType: "PULSE_WAVE_VELOCITY", + displayName: "Pulse-wave velocity", + unit: "m/s", + direction: "lower-better", + archetype: "physiological-vital", + }, + VASCULAR_AGE: { + id: "VASCULAR_AGE", + measurementType: "VASCULAR_AGE", + displayName: "Vascular age", + unit: "years", + direction: "lower-better", + archetype: "physiological-vital", + }, + // ── activity-fitness ── + STEPS: { + id: "STEPS", + measurementType: "ACTIVITY_STEPS", + displayName: "Steps", + unit: "steps/day", + direction: "higher-better", + normalRange: { low: 7000, high: 10000 }, + archetype: "activity-fitness", + }, + ACTIVE_ENERGY: { + id: "ACTIVE_ENERGY", + measurementType: "ACTIVE_ENERGY_BURNED", + displayName: "Active energy", + unit: "kcal/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + FLIGHTS_CLIMBED: { + id: "FLIGHTS_CLIMBED", + measurementType: "FLIGHTS_CLIMBED", + displayName: "Flights climbed", + unit: "flights/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + WALKING_RUNNING_DISTANCE: { + id: "WALKING_RUNNING_DISTANCE", + measurementType: "WALKING_RUNNING_DISTANCE", + displayName: "Walking + running distance", + unit: "m/day", + direction: "higher-better", + archetype: "activity-fitness", + }, + TIME_IN_DAYLIGHT: { + id: "TIME_IN_DAYLIGHT", + measurementType: "TIME_IN_DAYLIGHT", + displayName: "Time in daylight", + unit: "min/day", + direction: "higher-better", + normalRange: { low: 30, high: 120 }, + archetype: "activity-fitness", + }, + VO2_MAX: { + id: "VO2_MAX", + measurementType: "VO2_MAX", + displayName: "VO₂ max (cardio fitness)", + unit: "mL/(kg·min)", + direction: "higher-better", + archetype: "activity-fitness", + }, + // ── body-composition ── + TOTAL_BODY_WATER: { + id: "TOTAL_BODY_WATER", + measurementType: "TOTAL_BODY_WATER", + displayName: "Total body water", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + BONE_MASS: { + id: "BONE_MASS", + measurementType: "BONE_MASS", + displayName: "Bone mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + FAT_FREE_MASS: { + id: "FAT_FREE_MASS", + measurementType: "FAT_FREE_MASS", + displayName: "Fat-free mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + FAT_MASS: { + id: "FAT_MASS", + measurementType: "FAT_MASS", + displayName: "Fat mass", + unit: "kg", + direction: "lower-better", + archetype: "body-composition", + }, + MUSCLE_MASS: { + id: "MUSCLE_MASS", + measurementType: "MUSCLE_MASS", + displayName: "Muscle mass", + unit: "kg", + direction: "higher-better", + archetype: "body-composition", + }, + LEAN_BODY_MASS: { + id: "LEAN_BODY_MASS", + measurementType: "LEAN_BODY_MASS", + displayName: "Lean body mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + }, + VISCERAL_FAT: { + id: "VISCERAL_FAT", + measurementType: "VISCERAL_FAT", + displayName: "Visceral fat rating", + unit: "rating", + direction: "lower-better", + normalRange: { low: 1, high: 12 }, + archetype: "body-composition", + }, + // ── mobility-gait ── + WALKING_STEADINESS: { + id: "WALKING_STEADINESS", + measurementType: "WALKING_STEADINESS", + displayName: "Walking steadiness", + unit: "%", + direction: "higher-better", + normalRange: { low: 50, high: 100 }, + archetype: "mobility-gait", + }, + WALKING_ASYMMETRY: { + id: "WALKING_ASYMMETRY", + measurementType: "WALKING_ASYMMETRY", + displayName: "Walking asymmetry", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + }, + WALKING_DOUBLE_SUPPORT: { + id: "WALKING_DOUBLE_SUPPORT", + measurementType: "WALKING_DOUBLE_SUPPORT", + displayName: "Double-support time", + unit: "%", + direction: "lower-better", + normalRange: { low: 20, high: 40 }, + archetype: "mobility-gait", + }, + WALKING_STEP_LENGTH: { + id: "WALKING_STEP_LENGTH", + measurementType: "WALKING_STEP_LENGTH", + displayName: "Step length", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + }, + WALKING_SPEED: { + id: "WALKING_SPEED", + measurementType: "WALKING_SPEED", + displayName: "Walking speed", + unit: "m/s", + direction: "higher-better", + normalRange: { low: 1.2, high: 1.4 }, + archetype: "mobility-gait", + }, + // ── environmental-exposure ── + AUDIO_EXPOSURE_ENV: { + id: "AUDIO_EXPOSURE_ENV", + measurementType: "AUDIO_EXPOSURE_ENV", + displayName: "Environmental sound exposure", + unit: "dBA", + direction: "lower-better", + normalRange: { low: 0, high: 80 }, + archetype: "environmental-exposure", + }, + AUDIO_EXPOSURE_HEADPHONE: { + id: "AUDIO_EXPOSURE_HEADPHONE", + measurementType: "AUDIO_EXPOSURE_HEADPHONE", + displayName: "Headphone audio exposure", + unit: "dBA", + direction: "lower-better", + normalRange: { low: 0, high: 80 }, + archetype: "environmental-exposure", + }, + AUDIO_EXPOSURE_EVENT: { + id: "AUDIO_EXPOSURE_EVENT", + measurementType: "AUDIO_EXPOSURE_EVENT", + displayName: "Loud-exposure events", + unit: "events", + direction: "lower-better", + archetype: "environmental-exposure", + }, + // ── sleep (dedicated template) ── + SLEEP_DURATION: { + id: "SLEEP_DURATION", + measurementType: "SLEEP_DURATION", + // Stored in minutes (see schema MeasurementType.SLEEP_DURATION); the + // template surfaces hours for the model's prose. + displayName: "Sleep duration", + unit: "min", + direction: "target-band", + normalRange: { low: 420, high: 540 }, + archetype: "sleep", + }, +}; + +/** Closed set of ids the generic route accepts (Zod enum source). */ +export const METRIC_STATUS_IDS = Object.keys( + REGISTRY, +) as MetricStatusMetricId[]; + +/** Type guard narrowing an arbitrary string to a registered metric id. */ +export function isMetricStatusId( + value: string, +): value is MetricStatusMetricId { + return Object.prototype.hasOwnProperty.call(REGISTRY, value); +} + +/** Resolve the metadata for a metric id, or null when unregistered. */ +export function getMetricStatusMeta( + metric: string, +): MetricStatusMeta | null { + return isMetricStatusId(metric) ? REGISTRY[metric] : null; +} + +/** + * The cache/scope id for a metric assessment. The `metric:` prefix keeps + * the generic scopes disjoint from the seven bare specialised scope + * slugs while the trailing `-status.` substring (appended at cache-key + * time, like every other scope) keeps the eviction + invalidation + * sweeps matching. Example cache action: + * `insights.metric:RESTING_HEART_RATE-status.de`. + */ +export function metricStatusScope( + metric: MetricStatusMetricId, +): `metric:${MetricStatusMetricId}` { + return `metric:${metric}`; +} + +/** + * Map a generic metric id to its `InsightMetric` key so the shared + * `hasMetricData` gate can answer the data-availability question with no + * new branch. The two vocabularies line up one-to-one for every + * registered metric except `STEPS` / `ACTIVE_ENERGY`, whose + * `InsightMetric` keys match the id directly (the divergence is only at + * the `MeasurementType` layer, which `hasMetricData` does not consult). + */ +export function metricStatusInsightKey( + metric: MetricStatusMetricId, +): InsightMetric { + return metric as InsightMetric; +} diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts new file mode 100644 index 000000000..88a8d30dc --- /dev/null +++ b/src/lib/insights/metric-status.ts @@ -0,0 +1,349 @@ +/** + * v1.8.7.1 — generic per-HealthKit-metric assessment generator. + * + * The seven specialised generators (`pulse-status.ts`, …) each hand-roll + * a metric-specific snapshot + prompt. This one generator covers every + * registered HealthKit metric (`metric-status-registry.ts`) through the + * archetype prompt templates (`prompts/metric-archetypes.ts`), reusing + * the exact same machinery the specialised cards use: + * - the graded-series snapshot builder (`buildGradedSeriesWithRollups`), + * - the shared provider runner (`runStatusCompletion`), + * - the stale-while-revalidate cache (`readFreshStatusText` / + * `resolveReadOnlyStatusMiss`), + * - the standard cache-row persist (`persistStatusInsight`), + * - the previous-context comparison block (`getPreviousInsightContext`). + * + * The return shape is byte-identical to the specialised generators so + * `InsightStatusCard` consumes it unchanged. + * + * Empty-data guard (design §3): when the metric has no readings the + * generator returns an `insufficient` marker WITHOUT touching the + * provider chain — no LLM call, no cache write. The route maps that onto + * the card's insufficient-data state. + */ +import { prisma } from "@/lib/db"; +import { + getMetricArchetypeSystemPrompt, + getMetricArchetypeUserPrompt, +} from "@/lib/ai/prompts/metric-archetypes"; +import { + getMetricStatusMeta, + metricStatusScope, + type MetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; +import { + formatPreviousContextForPrompt, + getPreviousInsightContext, +} from "@/lib/insights/memory"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { applyPayloadBudget } from "@/lib/insights/bucket-series"; +import { + buildGradedSeriesWithRollups, + degradeStatusSnapshotToBudget, +} from "@/lib/insights/graded-series"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + summarizeSeries, +} from "@/lib/insights/status-shared"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { annotate } from "@/lib/logging/context"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +export interface MetricStatusResult { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + /** + * v1.8.7.1 — true when the metric has no data at all. The route maps + * this onto the card's insufficient-data state; no LLM was called. + */ + insufficient?: boolean; +} + +/** Count this metric's live (non-deleted) readings — the empty-data gate. */ +async function countMetricReadings( + userId: string, + meta: MetricStatusMeta, +): Promise { + return prisma.measurement.count({ + where: { userId, type: meta.measurementType, deletedAt: null }, + }); +} + +/** + * Generate (or read from cache) the generic assessment for one metric. + * Mirrors the specialised generators' contract: + * - cache hit (today, non-stub) → serve it, + * - `readOnly` miss → enqueue out-of-band + serve last-good + * (stale-while-revalidate), + * - empty data → `insufficient`, no LLM call, + * - otherwise → build snapshot, run completion, persist. + */ +export async function generateMetricStatus(args: { + metric: MetricStatusMetricId; + userId: string; + locale?: string | null; + force?: boolean; + /** Read-only navigation path — never blocks on the provider. */ + readOnly?: boolean; +}): Promise { + const meta = getMetricStatusMeta(args.metric); + if (!meta) { + // Defensive — the route Zod-validates against the registry, so an + // unknown id never reaches here. Treat as insufficient rather than + // throwing so a stale caller degrades gracefully. + return { + hasProvider: false, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; + } + + const locale = normalizeLocale(args.locale); + const force = args.force === true; + const readOnly = args.readOnly === true; + const scope = metricStatusScope(meta.id); + const cacheAction = `insights.${scope}-status.${locale}`; + const todayKey = toBerlinDayKey(new Date()); + + const cached = await readFreshStatusText({ + userId: args.userId, + cacheAction, + todayKey, + force, + }); + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; + } + + // Empty-data guard — no readings means no assessment to generate. Done + // BEFORE the read-only enqueue so an empty metric never queues an LLM + // job nor blocks on the provider. The nightly warm pass applies the same + // gate so the cron never burns a call on an empty metric either. + const readingCount = await countMetricReadings(args.userId, meta); + if (readingCount === 0) { + annotate({ + action: { name: "insights.metric-status.insufficient" }, + meta: { metric: meta.id }, + }); + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; + } + + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId: args.userId, + metric: scope, + locale, + }); + if (outcome.kind === "no-provider") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + // Stale-while-revalidate: serve the last good assessment (if any) + // instantly while the worker re-warms the cache; only fall to the + // empty preparing skeleton when none was ever produced. + return { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + }; + } + + const user = await prisma.user.findUnique({ + where: { id: args.userId }, + select: { dateOfBirth: true, gender: true }, + }); + + const now = new Date(); + + // Bounded raw read for the derived recent stats; the monthly/yearly + // tail comes from the rollup tier (full-history fallback on a miss), + // exactly as the specialised cards do. + const measurements = await prisma.measurement + .findMany({ + where: { userId: args.userId, type: meta.measurementType, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }) + .then((rows) => rows.reverse()); + + const points = measurements.map((m) => ({ + measuredAt: m.measuredAt, + value: m.value, + })); + const series = applyPayloadBudget(points, { now }); + const graded = await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + ); + const summary = summarizeSeries( + series.daily.map((bucket) => ({ value: bucket.value })), + ); + + const latest = series.daily[0] ?? null; + + const oldest = measurements.length > 0 ? measurements[0].measuredAt : null; + const newest = + measurements.length > 0 + ? measurements[measurements.length - 1].measuredAt + : null; + const totalSpanDays = + oldest && newest + ? Math.round( + (newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000), + ) + : 0; + const newestDaysAgo = newest + ? Math.round((Date.now() - newest.getTime()) / (24 * 60 * 60 * 1000)) + : null; + + const snapshot = { + locale, + generatedForDay: todayKey, + focus: meta.id, + metric: { + id: meta.id, + displayName: meta.displayName, + unit: meta.unit, + direction: meta.direction, + ...(meta.normalRange ? { normalRange: meta.normalRange } : {}), + }, + // Profile context for placement only when stored — the medical note + // limits population ranges to age + sex (NO ancestry/region). Omitted + // when the profile leaves them blank, so the model leans on baseline. + profile: { + ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null), + sex: user?.gender ?? null, + }, + dataCoverage: { + totalMeasurements: measurements.length, + totalSpanDays, + newestMeasurementDaysAgo: newestDaysAgo, + }, + [meta.id]: { + summary, + series: graded, + latestDayFocus: latest + ? { dayOffset: latest.dayOffset, value: latest.value } + : null, + }, + }; + + const shed = degradeStatusSnapshotToBudget( + snapshot as unknown as Record, + ); + const snapshotJson = JSON.stringify(snapshot, null, 2); + + annotate({ + action: { name: cacheAction }, + meta: { + payload_size_bytes: snapshotJson.length, + ...(shed.length > 0 ? { snapshot_shed: shed } : {}), + }, + }); + + const previousContext = await getPreviousInsightContext( + args.userId, + // memory.ts keys previous context by the `insights..` + // action; the generic scope (`metric:-status`) carries the same + // `-status` suffix so the comparison row reads back correctly. + `${scope}-status` as never, + locale, + 12, + ); + const previousContextBlock = formatPreviousContextForPrompt( + previousContext, + locale, + ); + + const outcome = await runStatusCompletion({ + userId: args.userId, + cacheAction, + systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + userPrompt: getMetricArchetypeUserPrompt( + meta, + snapshotJson, + todayKey, + locale, + previousContextBlock, + ), + temperature: 0.3, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + + const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + if (!text) { + throw new Error(`Metric-status summary was empty for ${meta.id}`); + } + + const updatedAt = await persistStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + locale, + text, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + }); + + return { hasProvider: true, text, cached: false, updatedAt }; +} + +export function resolveMetricStatusLocale( + locale: string | null | undefined, +): SupportedLocale { + return normalizeLocale(locale); +} diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index fc9e6e687..f5908cf7c 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/db"; import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; import { enqueueStatusGeneration, - type InsightStatusMetric, + type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; import { annotate } from "@/lib/logging/context"; @@ -176,7 +176,7 @@ export type ReadOnlyMissOutcome = */ export async function resolveReadOnlyStatusMiss(args: { userId: string; - metric: InsightStatusMetric; + metric: InsightStatusScope; locale: "de" | "en"; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 29b7d3e50..0d9ce5d53 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -51,6 +51,17 @@ vi.mock("@/lib/insights/medication-compliance-status", () => ({ vi.mock("@/lib/insights/general-status", () => ({ generateGeneralStatusForUser: vi.fn(), })); +// v1.8.7.1 — the generic metric generator also imports the provider +// chain transitively; stub it so the default generic warm pass never +// touches a live provider when the test does not inject its own. +vi.mock("@/lib/insights/metric-status", () => ({ + generateMetricStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: false, + updatedAt: new Date().toISOString(), + })), +})); import { runInsightPregenerate, @@ -321,6 +332,51 @@ describe("runInsightPregenerate — per-metric warm pass", () => { }); }); +describe("runInsightPregenerate — generic metric warm pass (v1.8.7.1)", () => { + it("warms the generic metric caches after a successful comprehensive generation", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "x" }); + // Stub the seven specialised generators so only the generic count + // is under test. + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(9); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(warmGenericMetrics).toHaveBeenCalledTimes(1); + expect(warmGenericMetrics).toHaveBeenCalledWith("u1", ["de", "en"]); + expect(result.metricAssessmentsWarmed).toBe(9); + }); + + it("does NOT warm the generic metric caches when the comprehensive pass skipped (no provider)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "en" }]); + const generate = vi + .fn() + .mockResolvedValue({ status: "skipped", reason: "no-provider" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: false, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(9); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + warmGenericMetrics, + }); + + expect(warmGenericMetrics).not.toHaveBeenCalled(); + expect(result.metricAssessmentsWarmed).toBe(0); + }); +}); + describe("queue registration", () => { const workerSrc = fs.readFileSync( path.resolve(__dirname, "../reminder-worker.ts"), diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 85c769e76..cea5a9418 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -62,6 +62,12 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { + METRIC_STATUS_IDS, + getMetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; @@ -114,6 +120,14 @@ export interface PregenerateRunResult { * one live status generation per card. This counts the warm writes. */ assessmentsWarmed: number; + /** + * v1.8.7.1 — count of generic per-HealthKit-metric assessment caches + * written warm across the run (the ~30 metric pages, data-bearing + * only). Tracked separately from `assessmentsWarmed` so the nightly + * dashboard can see the generic-tier coverage independently of the + * seven specialised cards. + */ + metricAssessmentsWarmed: number; } /** @@ -186,6 +200,76 @@ async function warmPerStatusCaches( return results.reduce((sum: number, n) => sum + n, 0); } +/** + * v1.8.7.1 — warm the generic per-HealthKit-metric assessment caches for + * one user, but ONLY for metrics that have data. A single grouped count + * answers "which metric types does this user have readings for?" in one + * query, so the cron never burns a (cheap) per-metric count nor (costly) + * an LLM call on a metric the user has never logged. The generic + * generator's own empty-data guard is the second line of defence, but the + * up-front filter keeps the cron from forcing ~30 metric generators per + * user when most have no data. + * + * Returns the count of fresh provider-backed assessments written. + */ +async function warmGenericMetricCaches( + prisma: PrismaClient, + userId: string, + locales: ReadonlyArray<"de" | "en">, +): Promise { + // One distinct read: the set of MeasurementTypes the user has live + // rows for. Best-effort — a read failure (or a worker prisma surface + // without `measurement`) yields zero metrics rather than aborting the + // user's whole pre-generation loop. + let rows: Array<{ type: string }>; + try { + rows = await prisma.measurement.findMany({ + where: { userId, deletedAt: null }, + distinct: ["type"], + select: { type: true }, + }); + } catch { + return 0; + } + const typesWithData = new Set(rows.map((r) => r.type)); + + const metricsWithData = METRIC_STATUS_IDS.filter( + (id: MetricStatusMetricId) => { + const meta = getMetricStatusMeta(id); + return meta !== null && typesWithData.has(meta.measurementType); + }, + ); + if (metricsWithData.length === 0) return 0; + + const limit = pLimit(WARM_PASS_CONCURRENCY); + const tasks = metricsWithData.flatMap((metric) => + locales.map((locale) => + limit(async () => { + try { + const outcome = await generateMetricStatus({ + metric, + userId, + locale, + force: true, + }); + // `insufficient` never reaches here (we pre-filtered to + // metrics with data), but guard anyway: count only a fresh + // provider-backed assessment. + return outcome.hasProvider && + !outcome.cached && + outcome.insufficient !== true + ? 1 + : 0; + } catch { + return 0; + } + }), + ), + ); + const results = await Promise.all(tasks); + return results.reduce((sum: number, n) => sum + n, 0); +} + interface PregenerateCandidate { id: string; locale: string | null; @@ -242,6 +326,15 @@ export async function runInsightPregenerate( ) => Promise; /** Injected for the test — defaults to the seven real status generators. */ statusGenerators?: ReadonlyArray; + /** + * v1.8.7.1 — injected for the test. Defaults to the real generic + * metric warm pass; the test stubs it to assert it runs only for + * data-bearing metrics without a live LLM. + */ + warmGenericMetrics?: ( + userId: string, + locales: ReadonlyArray<"de" | "en">, + ) => Promise; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -249,6 +342,10 @@ export async function runInsightPregenerate( const generate = options.generate ?? generateComprehensiveInsight; const statusGenerators = options.statusGenerators ?? DEFAULT_STATUS_GENERATORS; + const warmGenericMetrics = + options.warmGenericMetrics ?? + ((userId: string, locales: ReadonlyArray<"de" | "en">) => + warmGenericMetricCaches(prisma, userId, locales)); const result: PregenerateRunResult = { total: 0, @@ -258,6 +355,7 @@ export async function runInsightPregenerate( failed: 0, budgetBlocked: 0, assessmentsWarmed: 0, + metricAssessmentsWarmed: 0, }; // Master kill-switch — when the operator disabled the briefing @@ -329,6 +427,13 @@ export async function runInsightPregenerate( ["de", "en"], statusGenerators, ); + // v1.8.7.1 — warm the generic per-HealthKit-metric caches too, for + // the user's data-bearing metrics only (the helper filters via one + // grouped count, so an empty metric never reaches the provider). + result.metricAssessmentsWarmed += await warmGenericMetrics( + candidate.id, + ["de", "en"], + ); } } @@ -344,6 +449,7 @@ export async function runInsightPregenerate( failed: result.failed, budget_blocked: result.budgetBlocked, assessments_warmed: result.assessmentsWarmed, + metric_assessments_warmed: result.metricAssessmentsWarmed, }, }); diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 67581559e..bbe2a95ad 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -46,9 +46,26 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray = [ "medication-compliance", ]; +/** + * v1.8.7.1 — the generic per-HealthKit-metric assessment scopes ride the + * same on-demand queue. Their scope id is `metric:` (see + * `metricStatusScope` in the registry), which the worker dispatch routes + * to the generic generator rather than one of the seven specialised + * ones. The queue payload, the read-only resolver, and the enqueue helper + * all accept this broader scope so a generic card warms exactly like a + * specialised one. A specialised scope is one of `InsightStatusMetric`; + * a generic scope is any `metric:`-prefixed string. + */ +export type InsightStatusScope = InsightStatusMetric | `metric:${string}`; + export interface InsightStatusGeneratePayload { userId: string; - metric: InsightStatusMetric; + /** + * The scope to generate. A specialised scope (one of the seven) or a + * generic `metric:` scope (v1.8.7.1). The worker dispatch + * routes the prefix to the right generator. + */ + metric: InsightStatusScope; /** Resolved client locale — the cache key the route reads against. */ locale: "de" | "en"; } diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 6a00ef3b2..9ee562b2a 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -34,6 +34,8 @@ import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { isMetricStatusId } from "@/lib/insights/metric-status-registry"; import { INSIGHT_STATUS_GENERATE_QUEUE, INSIGHT_STATUS_GENERATE_CONCURRENCY, @@ -80,7 +82,30 @@ export async function runInsightStatusGenerate( payload: InsightStatusGeneratePayload, generators: Record = GENERATORS, ): Promise { - const generate = generators[payload.metric]; + // v1.8.7.1 — a `metric:` scope routes to the generic + // HealthKit-metric generator rather than one of the seven specialised + // ones. The generic generator applies its own empty-data guard, so a + // job enqueued for a metric that has since lost its data costs only a + // cheap count, not an LLM call. + if (payload.metric.startsWith("metric:")) { + const metricId = payload.metric.slice("metric:".length); + if (!isMetricStatusId(metricId)) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generateMetricStatus({ + metric: metricId, + userId: payload.userId, + locale: payload.locale, + force: true, + }); + return; + } + + const generate = generators[payload.metric as InsightStatusMetric]; if (!generate) { annotate({ action: { name: "insights.status.generate.unknown_metric" }, diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 448b91a42..6a0f3f9cc 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -46,6 +46,7 @@ import { import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -1097,6 +1098,64 @@ const insightsComprehensiveResponse = z "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", }); +// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is +// derived from the same registry the route validates against, so the +// spec, the route, and the cache scope cannot drift. The seven +// specialised metrics (weight / blood-pressure / pulse / bmi / mood / +// medication-compliance) keep their own routes and are NOT accepted here. +const metricStatusQuery = z + .object({ + metric: z + .enum(METRIC_STATUS_IDS as [string, ...string[]]) + .describe( + "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "MetricStatusQuery" }); + +const metricStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", + ), + }) + .meta({ + id: "MetricStatusResponse", + description: + "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + // v1.7.0 — unified dashboard first-paint snapshot. One GET that // assembles every above-the-fold tile field in a single round-trip. // Two-phase shape: `tiles` (fast, always present) + `extras` (thick, @@ -2350,6 +2409,31 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/metric-status": { + get: { + tags: ["Insights"], + summary: "Generic per-HealthKit-metric assessment", + description: + "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", + requestParams: { + query: metricStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + metricStatusResponse, + "MetricStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; export const openApiComponents: NonNullable = { From 16b0c349f2a1c5bfff00a1b26f69e10cd8317b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:17:06 +0200 Subject: [PATCH 02/17] feat(insights): add generic per-metric assessment loader Add a parameterised query key (insightsMetricStatus) and a useInsightMetricStatus hook that reads the generic per-metric assessment route GET /api/insights/metric-status?metric=. The seven bespoke status routes keep their own keys and hook union; this one covers the HealthKit metric sub-pages that share a single route keyed by the metric id. The hook mirrors the existing status loader: 8 s client ceiling, no inline retry, and the bounded preparing poll. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a3407a2f6c1e9f54c92a6f182db894ae0593e69f) --- src/hooks/use-insight-status.ts | 56 +++++++++++++++++++++++++++++++++ src/lib/query-keys.ts | 10 ++++++ 2 files changed, 66 insertions(+) diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 8b404d355..0ac0bc3ef 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -163,3 +163,59 @@ export function useInsightStatus(metric: InsightStatusMetric) { ), }); } + +/** + * v1.8.7.1 — generic per-metric assessment loader for the HealthKit + * metric sub-pages. The seven bespoke metrics above (`weight`, `pulse`, + * …) each own a hand-written `/api/insights/-status` route; the + * ~29 HealthKit pages instead share one generic route keyed by the + * metric id: + * + * GET /api/insights/metric-status?metric=&locale= + * + * where METRIC_ID is the existing HealthKit measurement identifier + * (`HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `SLEEP_DURATION`, …). + * The response envelope, the 8 s client ceiling, the no-retry policy and + * the bounded `preparing` poll are identical to `useInsightStatus` — the + * card consumes the same `InsightStatusData` shape for every scope. + * + * `enabled` lets the page suppress the fetch entirely when the metric has + * no data (the page renders the insufficient-data empty state instead of + * the card), so a brand-new account never fires an assessment round-trip. + */ +export function useInsightMetricStatus(metric: string, enabled = true) { + const { isAuthenticated } = useAuth(); + const { locale } = useTranslations(); + + return useQuery({ + queryKey: queryKeys.insightsMetricStatus(metric, locale), + queryFn: async (): Promise => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + STATUS_TIMEOUT_MS, + ); + try { + const res = await fetch( + `/api/insights/metric-status?metric=${encodeURIComponent( + metric, + )}&locale=${locale}`, + { signal: controller.signal }, + ); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: InsightStatusData }; + return json.data; + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: isAuthenticated && enabled, + staleTime: 60 * 1000, + retry: 0, + refetchInterval: (query) => + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + ), + }); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index dc967e3aa..0aeae6832 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -103,6 +103,16 @@ export const queryKeys = { ["insights", "mood-status", locale] as const, insightsMedicationComplianceStatus: (locale: string) => ["insights", "medication-compliance-status", locale] as const, + /** + * v1.8.7.1 — per-metric insight assessment for the HealthKit metric + * sub-pages. One generic route (`/api/insights/metric-status?metric=…`) + * backs every HealthKit metric, so the key is parameterised by the + * metric id alongside the locale rather than minting a bespoke factory + * entry per metric. The seven bespoke `*-status` routes above keep + * their own keys; this one covers the ~29 generic HealthKit pages. + */ + insightsMetricStatus: (metric: string, locale: string) => + ["insights", "metric-status", metric, locale] as const, medications: () => ["medications"] as const, medicationDetail: (id: string) => ["medications", id] as const, From b1b2e22eebdce2e998dd884cb713a3e2478f3919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:17:12 +0200 Subject: [PATCH 03/17] feat(insights): mount per-metric assessment on HealthKit sub-pages Add an optional statusMetric prop to HealthKitMetricPage that mounts InsightStatusCard beneath the chart, pointed at the generic metric-status route. The card renders only on the data-bearing branch; the insufficient-data branch keeps the existing empty-state note and never fires an assessment fetch. Wire statusMetric on the 29 HealthKit metric sub-pages and on the sleep page, which closes the assessment slot its deferral comment had left open. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 083044201af7715740ec0ee2f14c7240c4999fbf) --- src/app/insights/active-energy/page.tsx | 1 + src/app/insights/audio-events/page.tsx | 1 + src/app/insights/blood-glucose/page.tsx | 1 + src/app/insights/body-temperature/page.tsx | 1 + src/app/insights/body-water/page.tsx | 1 + src/app/insights/bone-mass/page.tsx | 1 + src/app/insights/daylight/page.tsx | 1 + src/app/insights/double-support-time/page.tsx | 1 + src/app/insights/environmental-audio/page.tsx | 1 + src/app/insights/fat-free-mass/page.tsx | 1 + src/app/insights/fat-mass/page.tsx | 1 + src/app/insights/flights-climbed/page.tsx | 1 + src/app/insights/headphone-audio/page.tsx | 1 + src/app/insights/hrv/page.tsx | 1 + src/app/insights/lean-body-mass/page.tsx | 1 + src/app/insights/muscle-mass/page.tsx | 1 + src/app/insights/oxygen/page.tsx | 1 + src/app/insights/pulse-wave-velocity/page.tsx | 1 + src/app/insights/respiratory-rate/page.tsx | 1 + src/app/insights/resting-pulse/page.tsx | 1 + src/app/insights/skin-temperature/page.tsx | 1 + src/app/insights/sleep/page.tsx | 35 +++-- src/app/insights/step-length/page.tsx | 1 + src/app/insights/vascular-age/page.tsx | 1 + src/app/insights/visceral-fat/page.tsx | 1 + src/app/insights/walking-asymmetry/page.tsx | 1 + src/app/insights/walking-distance/page.tsx | 1 + src/app/insights/walking-heart-rate/page.tsx | 1 + src/app/insights/walking-speed/page.tsx | 1 + src/app/insights/walking-steadiness/page.tsx | 1 + .../healthkit-metric-page-status.test.tsx | 140 ++++++++++++++++++ .../insights/healthkit-metric-page.tsx | 37 +++++ 32 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/components/insights/__tests__/healthkit-metric-page-status.test.tsx diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index 1fcf9dc01..de9a57fd9 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() { return ( {/* - v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all - mount `` underneath the chart so the user - sees a written per-section assessment. Sleep has no - `/api/insights/sleep-status` route yet (the v1.4.23 schema - landed the data but the assessment-generation pass was deferred - to the v1.5 iOS sprint where the Apple-Health sleep snapshot - will inform the prompt). No per-section assessment yet; the - structural slot waits for v1.5 where the route + hook key will - wire in. + v1.8.7.1 — the per-section sleep assessment lands via the generic + metric-status route (`?metric=SLEEP_DURATION`), closing the slot + the v1.4.28 deferral left open. The card consumes the same + `InsightStatusData` envelope every sibling sub-page reads. */} + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + preparing={status?.preparing ?? false} + /> ); } diff --git a/src/app/insights/step-length/page.tsx b/src/app/insights/step-length/page.tsx index 31ac8be07..56c3a62d0 100644 --- a/src/app/insights/step-length/page.tsx +++ b/src/app/insights/step-length/page.tsx @@ -16,6 +16,7 @@ export default function InsightsSchrittlaengePage() { return ( ` mounts the generic + * per-metric assessment card beneath the chart when the metric has data, + * and falls back to the existing insufficient-data empty state otherwise. + * + * The page leans on several hooks (`useAuth`, `useInsightsAnalytics`, + * `useInsightMetricStatus`, …); the test mocks them so the data / + * empty branch is deterministic. The chart is `next/dynamic`-imported, + * so SSR renders its stub — enough to verify the assessment slot. + */ + +vi.mock("next/dynamic", () => ({ + default: () => { + const Stub = () =>
; + Stub.displayName = "HealthChartStub"; + return Stub; + }, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-insights-layout-prefs", () => ({ + useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), +})); + +const analyticsMock = vi.fn(); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => analyticsMock(), +})); + +const metricStatusMock = vi.fn(); +vi.mock("@/hooks/use-insight-status", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args), + }; +}); + +import { HealthKitMetricPage } from "../healthkit-metric-page"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + +const baseProps = { + measurementType: "HEART_RATE_VARIABILITY", + insightMetric: "HEART_RATE_VARIABILITY" as const, + chartKey: "hrv" as const, + i18nPrefix: "insights.hrv", + color: "#bd93f9", + unit: "ms", + emptyStateIcon: null, +}; + +const settledStatus: InsightStatusData = { + hasProvider: true, + text: "Your HRV has trended upward over the last month.", + cached: false, + updatedAt: null, +}; + +beforeEach(() => { + analyticsMock.mockReset(); + metricStatusMock.mockReset(); +}); + +describe(" status mount", () => { + it("mounts the assessment card when statusMetric is set and the metric has data", () => { + analyticsMock.mockReturnValue({ + data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } }, + isEmpty: false, + }); + metricStatusMock.mockReturnValue({ + data: settledStatus, + isLoading: false, + }); + + const html = render( + , + ); + + expect(html).toContain("Your HRV has trended upward"); + // The card fetch is enabled (statusMetric present + data present). + expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", true); + }); + + it("does not mount the assessment card when statusMetric is omitted", () => { + analyticsMock.mockReturnValue({ + data: { summaries: { HEART_RATE_VARIABILITY: { count: 12 } } }, + isEmpty: false, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render(); + + // No assessment heading, and the generic fetch is disabled. + expect(html).not.toContain("Assessment"); + expect(metricStatusMock).toHaveBeenCalledWith("", false); + }); + + it("renders the insufficient-data empty state (no card) when the metric has no data", () => { + analyticsMock.mockReturnValue({ data: null, isEmpty: true }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + const html = render( + , + ); + + // Empty branch: no settled assessment text, and the status fetch is + // disabled so a source-less account never fires the round-trip. + expect(html).not.toContain("Your HRV has trended upward"); + expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", false); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index c4a7a2f90..929b9b17e 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -3,7 +3,10 @@ import Link from "next/link"; import type { ComponentProps, ReactNode } from "react"; +import { Sparkles } from "lucide-react"; + import { useAuth } from "@/hooks/use-auth"; +import { useInsightMetricStatus } from "@/hooks/use-insight-status"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useTranslations } from "@/lib/i18n/context"; @@ -11,6 +14,7 @@ import type { InsightMetric } from "@/lib/insights/metric-availability"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; @@ -96,6 +100,17 @@ export interface HealthKitMetricPageProps { * rather than a bespoke module. Omit it for metrics without a target. */ targetSummarySlug?: string; + /** + * v1.8.7.1 — when set, mounts `` beneath the chart, + * pointed at the generic per-metric assessment route + * (`/api/insights/metric-status?metric=`). The value is + * the HealthKit metric identifier the route keys on — almost always the + * same string as `measurementType`. The card is only rendered on the + * data-bearing branch; the empty (insufficient-data) branch keeps the + * existing `` note and never fires an assessment + * fetch. Omit it for metrics that should not carry an assessment. + */ + statusMetric?: string; } export function HealthKitMetricPage({ @@ -113,12 +128,22 @@ export function HealthKitMetricPage({ valueScale, explainerMetric, targetSummarySlug, + statusMetric, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + + // v1.8.7.1 — generic per-metric assessment. The hook must run on every + // render (rules of hooks), so it is always called; `enabled` gates the + // actual fetch on a configured `statusMetric` AND the presence of data + // so an insufficient-data page never fires an assessment round-trip. + const { data: status, isLoading: isStatusLoading } = useInsightMetricStatus( + statusMetric ?? "", + Boolean(statusMetric) && !isEmpty, + ); const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED @@ -199,6 +224,18 @@ export function HealthKitMetricPage({ {targetSummarySlug ? ( ) : null} + {statusMetric ? ( + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + preparing={status?.preparing ?? false} + /> + ) : null} ); } From a9aa0b8531aee5886733024f023db14c3eb09509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:14:15 +0200 Subject: [PATCH 04/17] fix(ai): track the ChatGPT Codex backend's current model slugs The ChatGPT Codex backend rotated its ChatGPT-auth model slugs off the `-codex` specialist line onto the plain `gpt-5.x` line, so the in-code default ladder (`gpt-5.3-codex`, `gpt-5-codex`, `gpt-5`, `gpt-4o`) is now rejected and insight generation fails with a 503 on a fresh deploy that has no `CODEX_MODEL_FALLBACK_CHAIN` override. Replace the default ladder with the current accepted set, in order: `gpt-5.5` (current default across paid tiers), `gpt-5.4` and `gpt-5.4-mini` (documented fallbacks), `gpt-5.3-codex` (late rung, still accepted intermittently), `gpt-5.2` (legacy floor). The fall-through-on-rejection walk, the working-slug cache, and the env override path are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit bced19de2d4f0788bfd6fa56a78e719347776a1f) --- .../ai/__tests__/codex-slug-fallback.test.ts | 35 ++++++++++++++++++- src/lib/ai/codex-client.ts | 17 ++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index d6de7e3a7..98c8c2a9d 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -337,6 +337,39 @@ describe("Codex slug fallback chain", () => { expect(helper(400, "unrelated 400")).toBe(false); }); + it("DEFAULT_SLUG_FALLBACK_CHAIN tracks the current ChatGPT-auth ladder", () => { + // The backend rotated its ChatGPT-auth slugs onto the gpt-5.x line on + // 2026-06-02; the in-code default must match the accepted set, in order. + expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + ]); + }); + + it("loadFallbackChain falls back to the default ladder with no env override", () => { + const origModel = process.env.CODEX_MODEL; + const origChain = process.env.CODEX_MODEL_FALLBACK_CHAIN; + delete process.env.CODEX_MODEL; + delete process.env.CODEX_MODEL_FALLBACK_CHAIN; + try { + expect(__test.loadFallbackChain()).toEqual([ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.2", + ]); + } finally { + if (origModel === undefined) delete process.env.CODEX_MODEL; + else process.env.CODEX_MODEL = origModel; + if (origChain === undefined) delete process.env.CODEX_MODEL_FALLBACK_CHAIN; + else process.env.CODEX_MODEL_FALLBACK_CHAIN = origChain; + } + }); + it("loadFallbackChain folds CODEX_MODEL into position 0", () => { const orig = process.env.CODEX_MODEL; process.env.CODEX_MODEL = "custom-pinned-slug"; @@ -344,7 +377,7 @@ describe("Codex slug fallback chain", () => { const chain = __test.loadFallbackChain(); expect(chain[0]).toBe("custom-pinned-slug"); // Defaults still present after position 0. - expect(chain).toContain("gpt-5.3-codex"); + expect(chain).toContain("gpt-5.5"); // No duplicates. const seen = new Set(chain); expect(seen.size).toBe(chain.length); diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 0309abcdd..1c0c6b375 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -33,16 +33,23 @@ import { const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; /** - * Default fallback chain — most-current-first as of 2026-05-09. Override + * Default fallback chain — most-current-first as of 2026-06-02. Override * via `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated) on apps01 if a * specific plan ladder needs different ordering. `CODEX_MODEL` is * folded into position 0 of the chain when set; duplicates are dropped. + * + * The ChatGPT Codex backend rotated its ChatGPT-auth slugs off the + * `-codex` specialist line onto the plain `gpt-5.x` line on 2026-06-02; + * the former `-codex` defaults are now rejected with a 503 "all + * configured Codex slugs were rejected". The ladder below tracks the + * current accepted set. */ const DEFAULT_SLUG_FALLBACK_CHAIN = [ - "gpt-5.3-codex", // verified accepted on Plus/Pro 2026-05-09 - "gpt-5-codex", // historical default — kept as second-chance retry; backend may flip back - "gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05; safe to keep as ladder-rung - "gpt-4o", // last-ditch capability fallback + "gpt-5.5", // current default across paid tiers as of 2026-06-02 + "gpt-5.4", // documented fallback + "gpt-5.4-mini", // documented lighter-weight fallback + "gpt-5.3-codex", // late rung — still accepted intermittently + "gpt-5.2", // legacy floor ] as const; function loadFallbackChain(): string[] { From ee845946fa625ba0b862fc2fdd2dad30d3b66557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:21:16 +0200 Subject: [PATCH 05/17] fix(insights): place metric detail back-nav above the heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metric values subpage (/insights/values/[type]) and the workout detail page rendered the back link as the first child of SubPageShell, which paints it below the page heading. Add an optional backLink slot to SubPageShell that renders above the header — the standard back-nav placement — and move both pages' back links into it. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 72a73a52eb8295c9e70a3456972476b8c4469f07) --- src/app/insights/values/[type]/page.tsx | 27 ++++++++++--------- src/app/insights/workouts/[id]/page.tsx | 26 +++++++++--------- src/app/settings/sources/page.tsx | 23 ---------------- .../__tests__/sub-page-shell.test.tsx | 17 ++++++++++++ src/components/insights/sub-page-shell.tsx | 12 +++++++++ 5 files changed, 57 insertions(+), 48 deletions(-) delete mode 100644 src/app/settings/sources/page.tsx diff --git a/src/app/insights/values/[type]/page.tsx b/src/app/insights/values/[type]/page.tsx index e1915e2a3..fd1ff6556 100644 --- a/src/app/insights/values/[type]/page.tsx +++ b/src/app/insights/values/[type]/page.tsx @@ -47,20 +47,21 @@ export default function InsightsMetricValuesPage({ + + ); diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index d648a8b06..f55155ff5 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -40,19 +40,21 @@ export default function InsightsWorkoutDetailPage({ + + + {t("insights.workouts.detail.backToList")} + + + } > - - {isLoading ? (
diff --git a/src/app/settings/sources/page.tsx b/src/app/settings/sources/page.tsx deleted file mode 100644 index 198bb3d30..000000000 --- a/src/app/settings/sources/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { permanentRedirect } from "next/navigation"; - -/** - * v1.4.34 IW-D — `/settings/sources` permanent redirect. - * - * The Sources settings section was merged into `/settings/thresholds` - * (now titled "Targets & Sources" / "Zielwerte & Quellen"). The two - * editors share the same per-metric configuration shelf and reading - * them side-by-side on one page matches how a user actually thinks - * about metric configuration ("for this metric, which sources do I - * trust AND what range counts as healthy?"). Per - * `.planning/research/v1434-r-2-carryover-scope.md` §7 and - * `.planning/round-v1433-audit-menu.md` §7.1 (item 3). - * - * 308 (`permanentRedirect`) keeps the request method intact so any - * external system that POSTs (none today, but cheap insurance) follows - * through with its original method. Existing bookmarks, iOS Settings - * deep-links, and any docs that referenced `/settings/sources` - * continue to resolve. - */ -export default function SettingsSourcesRedirect(): never { - permanentRedirect("/settings/thresholds"); -} diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index 46394c46d..07653ce87 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -92,6 +92,23 @@ describe("", () => { expect(probeIdx).toBeLessThan(headerEnd); }); + it("renders the back link above the heading when supplied", () => { + const html = render( + back} + > + + , + ); + // v1.8.7.1 — the back-nav leads the page, above the heading. + const backIdx = html.indexOf('data-slot="back-probe"'); + const headerIdx = html.indexOf(" { const html = render( diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index e12d34a60..d4b108dab 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -31,6 +31,15 @@ import { cn } from "@/lib/utils"; export interface SubPageShellProps { title: string; + /** + * v1.8.7.1 — optional back-navigation control rendered ABOVE the page + * heading, the standard back-nav placement. Detail/values sub-pages + * (`/insights/values/[type]`, `/insights/workouts/[id]`) pass their + * "back to …" link here so it leads the page rather than trailing the + * content. Omitted on the mother page and category pages reached via the + * tab strip. + */ + backLink?: ReactNode; /** Optional accent badge (status, dataset coverage, …). */ badge?: ReactNode; /** @@ -108,6 +117,7 @@ export interface SubPageShellProps { export function SubPageShell({ title, + backLink, badge, description, explainerMetric, @@ -142,6 +152,8 @@ export function SubPageShell({ return (
+ {/* v1.8.7.1 — back-nav leads the page, above the heading. */} + {backLink}
{/* v1.8.6 — the heading row pins the Coach icon top-right while the title / nudge / badge wrap together on the left. From 708d012d281474718110a3a280744cbd4a213306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:21:21 +0200 Subject: [PATCH 06/17] fix(ui): make select trigger horizontal padding symmetric The select trigger used pl-3 pr-2 (12 px left, 8 px right), parking the chevron visibly closer to the right edge than the value text sits from the left. Use px-3 so both gutters are 12 px; the gap-2 between value and chevron and the chevron's shrink-0 keep it from crowding or clipping. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit aaea4ea8102d8f74ea039fc6764ee2e3e5474afc) --- src/components/ui/select.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index d69efe4aa..d81dbcd6b 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -41,19 +41,15 @@ function SelectTrigger({ // clear WCAG 2.5.5 and shrinks to 40 px on sm+ to match the // desktop input rhythm. `size=sm` (filter chips) intentionally // keeps the 32 px footprint. - // v1.4.37 W4a item 4 — split the trigger's horizontal padding - // (`pl-3 pr-2`) so the trailing chevron parks at the same - // visual gutter as the browser-native date-input calendar - // icon. The old uniform `px-3` parked the chevron 12 px from - // the edge, which read visibly tighter than the date-picker - // glyph when both controls shared a form row. - // v1.4.38 W-D P1-6 — drop the `[&_svg:last-child]:mr-1` - // double-shift and tighten `pr-2.5` to `pr-2`. The previous - // 14 px combined gutter (10 + 4) hit Chromium-Material native - // date-inputs cleanly but lagged ~4 px against Safari and - // Chromium-legacy themes; `pr-2` (8 px) lands the chevron - // inside the gutter the non-Material engines actually paint. - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + // v1.8.7.1 — symmetric horizontal padding. The earlier asymmetric + // `pl-3 pr-2` (12 px left / 8 px right) parked the trailing chevron + // visibly closer to the right edge than the value text sits from the + // left, so the trigger read lopsided. Match the right gutter to the + // left text padding (`px-3` → both 12 px) so the chevron and the + // value are inset equally; `gap-2` between them keeps the chevron + // from crowding a long value, and `shrink-0` guarantees it never + // clips. + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...props} From 78b76d2ac5f3d9b6357d116ca3bf5c4babac8b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:21:38 +0200 Subject: [PATCH 07/17] feat(settings): split targets and sources, rename AI Insights section Three related settings changes: - Split the combined Targets & Sources page back into two pages: Targets (/settings/thresholds) owns the per-metric target-range editor and Sources (/settings/sources) owns the source-priority and device-type ladders. Each is its own left-nav entry and route, served by the dynamic [section] route. The former /settings/sources permanent redirect is removed since the route now renders the page directly. - Rename the AI Insights settings section and nav label so the AI nature is explicit: 'AI Insights' (EN) / 'KI-Auswertungen' (DE), replacing the bare 'Insights' / 'Auswertungen'. - Each page renders its own h1 heading consistent with sibling settings pages. Drop the now-unused embedded mode from SourcesSection. Update the section titles and descriptions across all six locale bundles. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 5d8842934b985eb8ce037993a63bb0736ac0c7bd) --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 10 +-- messages/fr.json | 8 +-- messages/it.json | 10 +-- messages/pl.json | 10 +-- src/app/settings/[section]/page.tsx | 10 +-- .../settings/__tests__/sections.test.tsx | 39 +++++------ .../__tests__/settings-shell.test.tsx | 42 ++++++------ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 16 ++--- src/components/settings/settings-shell.tsx | 18 ++--- src/components/settings/sources-section.tsx | 66 +++++++------------ .../settings/thresholds-section.tsx | 46 ++++--------- 14 files changed, 122 insertions(+), 167 deletions(-) diff --git a/messages/de.json b/messages/de.json index dd3303220..911d740e2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2632,8 +2632,8 @@ "description": "Kachel-Layout und Reihenfolge." }, "thresholds": { - "title": "Zielwerte & Quellen", - "description": "Pro Metrik: eigener Zielbereich und welche Quelle führt, wenn mehrere am selben Tag denselben Wert liefern." + "title": "Zielwerte", + "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte." }, "sources": { "title": "Quellen", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Auswertungen", + "title": "KI-Auswertungen", "description": "Provider, Modell, API-Key." }, "api": { diff --git a/messages/en.json b/messages/en.json index 94ec0d550..fdcd403c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2632,8 +2632,8 @@ "description": "Tile layout and order." }, "thresholds": { - "title": "Targets & Sources", - "description": "Per metric: your own target range and which source wins when more than one records the same value on the same day." + "title": "Targets", + "description": "Per metric: your own target range for healthy values." }, "sources": { "title": "Sources", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Insights", + "title": "AI Insights", "description": "Provider, model, key." }, "api": { diff --git a/messages/es.json b/messages/es.json index 4953bba1d..e719fe090 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2632,12 +2632,12 @@ "description": "Tile layout and order." }, "thresholds": { - "title": "Objetivos y fuentes", - "description": "Por métrica: tu propio rango objetivo y qué fuente prevalece cuando varias registran el mismo valor el mismo día." + "title": "Objetivos", + "description": "Por métrica: tu propio rango objetivo de valores saludables." }, "sources": { - "title": "Sources", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "title": "Fuentes", + "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", "redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Insights", + "title": "AI Insights", "description": "Provider, model, key." }, "api": { diff --git a/messages/fr.json b/messages/fr.json index d79dcf777..5904ceff4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2632,12 +2632,12 @@ "description": "Tile layout and order." }, "thresholds": { - "title": "Objectifs et sources", - "description": "Par métrique : votre propre plage cible et quelle source prévaut quand plusieurs enregistrent la même valeur le même jour." + "title": "Objectifs", + "description": "Par métrique : votre propre plage cible pour des valeurs saines." }, "sources": { "title": "Sources", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", "redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Insights", + "title": "AI Insights", "description": "Provider, model, key." }, "api": { diff --git a/messages/it.json b/messages/it.json index 528df33c0..2d58c19a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2632,12 +2632,12 @@ "description": "Tile layout and order." }, "thresholds": { - "title": "Obiettivi e fonti", - "description": "Per metrica: il tuo intervallo target e quale fonte prevale quando più di una registra lo stesso valore lo stesso giorno." + "title": "Obiettivi", + "description": "Per metrica: il tuo intervallo target per valori sani." }, "sources": { - "title": "Sources", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "title": "Fonti", + "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", "redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Insights", + "title": "AI Insights", "description": "Provider, model, key." }, "api": { diff --git a/messages/pl.json b/messages/pl.json index 1f43336f1..7030787dd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2632,12 +2632,12 @@ "description": "Tile layout and order." }, "thresholds": { - "title": "Cele i źródła", - "description": "Dla każdej metryki: własny zakres docelowy i które źródło wygrywa, gdy kilka rejestruje tę samą wartość tego samego dnia." + "title": "Cele", + "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości." }, "sources": { - "title": "Sources", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", + "title": "Źródła", + "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", "redirectNotice": "Źródła są teraz częścią „Cele i źródła”.", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", @@ -2688,7 +2688,7 @@ } }, "ai": { - "title": "Insights", + "title": "AI Insights", "description": "Provider, model, key." }, "api": { diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 81b86cce4..38e5fadae 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -11,11 +11,12 @@ import { ExportSection } from "@/components/settings/export-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { NotificationsSection } from "@/components/settings/notifications-section"; import { SectionPlaceholder } from "@/components/settings/section-placeholder"; -// v1.4.34 IW-D — `sources` slug was retired (merged into `thresholds`). -// The combined "Targets & Sources" component lives in -// `thresholds-section.tsx`; `/settings/sources` is now a -// `permanentRedirect` handled by the dedicated route file. +// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two +// separate sections again. `ThresholdsSection` renders the target-range +// editor; `SourcesSection` (standalone mode) renders the source-priority +// ladders. Both are served by this dynamic route. import { ThresholdsSection } from "@/components/settings/thresholds-section"; +import { SourcesSection } from "@/components/settings/sources-section"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -49,6 +50,7 @@ const SECTION_COMPONENTS: Record< notifications: NotificationsSection, dashboard: DashboardSection, thresholds: ThresholdsSection, + sources: () => , api: ApiSection, export: ExportSection, advanced: AdvancedSection, diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 88300feb2..5a915c73e 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -79,15 +79,6 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({ vi.mock("@/components/settings/thresholds-editor-section", () => ({ ThresholdsEditorSection: () =>
, })); -// v1.4.34 IW-D — `` now also embeds -// `` so the combined "Targets & -// Sources" page reads as one shelf. The SourcesSection itself owns -// React Query and TanStack mutation wiring; stub it at the import -// boundary to keep the SSR smoke test focused on the wrapper. -vi.mock("@/components/settings/sources-section", () => ({ - SourcesSection: () =>
, -})); - import { I18nProvider } from "@/lib/i18n/context"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; @@ -169,12 +160,12 @@ describe("settings sections — SSR smoke", () => { expect(html).toContain("Tour ansehen"); }); - it(" renders the Insights card", () => { + it(" renders the AI Insights heading", () => { const html = render(); - // v1.4.33 IW7 — section renamed from "AI Insights" to "Insights" - // per the Marc-Voice rule. The card title is replaced with a sparkles - // icon + status badges, and the section H1 carries the only heading. - expect(html).toContain("Insights"); + // v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the + // surface is explicit (was the bare "Insights"). The section H1 carries + // the only heading. + expect(html).toContain("AI Insights"); expect(html).toContain("settings-section-ai-title"); }); @@ -230,26 +221,26 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain(">Jetzt prüfen<"); }); - it(" renders heading and embeds both editors (v1.4.34 IW-D merge)", () => { + it(" renders the Targets heading and the threshold editor only", () => { // v1.4.16 phase B6 contract: the route wrapper is named // `` (was ``), lives // at `thresholds-section.tsx`, and embeds the inner editor under // `` (was ``). - // v1.4.34 IW-D — the wrapper now also embeds - // `` so the page is the merged - // "Targets & Sources" surface. Both editor mounts must be present. + // v1.8.7.1 — Sources split back onto its own page, so this wrapper + // renders the target-range editor only. const html = render(); - // v1.4.34 IW-D: section renamed to "Targets & Sources". - expect(html).toContain("Targets & Sources"); + // v1.8.7.1: section renamed to "Targets". + expect(html).toContain("Targets"); expect(html).toContain('data-testid="thresholds-editor"'); - expect(html).toContain('data-testid="sources-section"'); + expect(html).not.toContain('data-testid="sources-section"'); expect(html).not.toContain("settings.sections.thresholds."); }); - it(" resolves the German title (B6 rename + IW-D merge)", () => { + it(" resolves the German title", () => { const html = render(, "de"); - expect(html).toContain("Zielwerte & Quellen"); + // v1.8.7.1 — German title is "Zielwerte" (Targets only). + expect(html).toContain("Zielwerte"); expect(html).toContain('data-testid="thresholds-editor"'); - expect(html).toContain('data-testid="sources-section"'); + expect(html).not.toContain('data-testid="sources-section"'); }); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index c5f61ea90..2a2752820 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -35,7 +35,7 @@ function renderShell(props: { } describe("SETTINGS_SECTION_SLUGS", () => { - it("declares the ten sections agreed for v1.4.34", () => { + it("declares the eleven sections", () => { // Order matters — `generateStaticParams()` and the sidebar derive their // ordering from this constant, so a reorder is a behaviour change. // v1.4.3 split the dashboard panel: layout stays under `dashboard`, @@ -44,19 +44,17 @@ describe("SETTINGS_SECTION_SLUGS", () => { // v1.4.16 phase B7 added the consolidated `export` section between // `api` and `advanced` so every "give me my data out" path lives in // one place. - // v1.4.25 W5e added `sources` between `thresholds` and `ai` so the - // per-metric-class source-priority surface lives next to the other - // analytics-shaping controls (thresholds → ranges; sources → - // canonical source per metric). - // v1.4.34 IW-D — `sources` slug was retired (merged into - // `thresholds`, now "Targets & Sources"). `/settings/sources` stays - // alive as a `permanentRedirect`. Section count: 10 (was 11). + // v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34 + // IW-D merged it into `thresholds`. + // v1.8.7.1 — `sources` (Sources) is its own slug again, sitting + // between `thresholds` (Targets) and `ai`. Section count: 11. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", "integrations", "notifications", "dashboard", "thresholds", + "sources", "ai", "api", "export", @@ -164,11 +162,11 @@ describe("", () => { // in the sidebar user-card dropdown. Route `/settings/about` is // still alive for direct links. expect(html).not.toContain('href="/settings/about"'); - // v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources - // under `/settings/thresholds`. The Sources nav entry must not - // appear; the route stays alive via permanentRedirect. - expect(html).not.toContain('href="/settings/sources"'); - expect(html).toContain("Targets & Sources"); + // v1.8.7.1 — Targets and Sources are two separate nav entries again. + expect(html).toContain('href="/settings/thresholds"'); + expect(html).toContain('href="/settings/sources"'); + expect(html).toContain("Targets"); + expect(html).toContain("Sources"); }); it("resolves every section title via the i18n provider — German", () => { @@ -184,21 +182,21 @@ describe("", () => { // per-metric overrides moved out into their own "Persönliche Zielwerte" // section, which is the new entry below the Dashboard one. expect(html).toContain("Dashboard"); - // v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to - // "Zielwerte & Quellen" (combined with the former Sources section). - expect(html).toContain("Zielwerte & Quellen"); - // v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per - // the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy). - expect(html).toContain("Auswertungen"); + // v1.8.7.1 — Targets and Sources are two separate German nav + // entries again: "Zielwerte" and "Quellen". + expect(html).toContain("Zielwerte"); + expect(html).toContain("Quellen"); + // v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in + // German (the "KI" prefix makes the AI nature explicit). + expect(html).toContain("KI-Auswertungen"); // API & Tokens is identical in both locales (proper noun + ampersand) expect(html).toContain("API & Tokens"); expect(html).toContain("Erweitert"); // v1.4.33 IW7 — "Über" (About) section removed from the in-shell // nav, folded into the sidebar user-card dropdown ("Über HealthLog"). expect(html).not.toContain('href="/settings/about"'); - // v1.4.34 IW-D — Sources nav entry retired (merged into Targets & - // Sources). - expect(html).not.toContain('href="/settings/sources"'); + // v1.8.7.1 — both Targets and Sources nav entries are present. + expect(html).toContain('href="/settings/sources"'); }); it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index 0a8681a54..3f05ea25d 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -16,6 +16,7 @@ import { Download, Info, KeyRound, + Layers, LayoutDashboard, Link2, Settings2, @@ -35,6 +36,7 @@ const SLUG_ICON: Record = { notifications: Bell, dashboard: LayoutDashboard, thresholds: SlidersHorizontal, + sources: Layers, ai: Sparkles, api: KeyRound, export: Download, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index e242d9891..76f010ef5 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -6,20 +6,20 @@ * graph. */ -// v1.4.34 IW-D — `sources` slug was retired. The two former sections -// (`/settings/thresholds` and `/settings/sources`) merged into a single -// "Targets & Sources" page under `/settings/thresholds`. The historic -// `/settings/sources` URL stays alive as a `permanentRedirect` (see -// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS, -// docs) follow through unchanged. Per -// `.planning/research/v1434-r-2-carryover-scope.md` §7 and -// `.planning/round-v1433-audit-menu.md` §7.1 (item 3). +// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are again two +// separate settings pages, each its own left-nav entry and route. They were +// merged into one "Targets & Sources" page in v1.4.34 IW-D, but the two +// editors drive distinct mutation flows (`/api/user/thresholds` vs +// `/api/auth/me/source-priority`) and reading them as one page made the +// surface long; splitting them back keeps each concern self-contained. +// Both slugs are served by the dynamic `[section]` route below. export const SETTINGS_SECTION_SLUGS = [ "account", "integrations", "notifications", "dashboard", "thresholds", + "sources", "ai", "api", "export", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 4e40e80d7..22043e34a 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -21,6 +21,7 @@ import { Bell, Download, KeyRound, + Layers, LayoutDashboard, Link2, Settings2, @@ -74,14 +75,10 @@ interface SettingsSection { * navigation. Direct links (the user-card dropdown, the public * `/about` permalink) continue to resolve to the same component. * - * v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled - * "Targets & Sources" / "Zielwerte & Quellen"). The combined section - * surfaces per-metric source priority alongside the threshold inputs - * on a single page. `/settings/sources` stays alive as a - * `permanentRedirect` so external bookmarks keep working; the slug - * was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic - * `[section]` route no longer serves it. Per - * `.planning/research/v1434-r-2-carryover-scope.md` §7. + * v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two + * separate sidebar entries again (merged into a single "Targets & + * Sources" page in v1.4.34 IW-D, split back here). Both are served by + * the dynamic `[section]` route. */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { slug: "account", titleKey: "settings.sections.account.title", icon: User }, @@ -105,6 +102,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.thresholds.title", icon: SlidersHorizontal, }, + { + slug: "sources", + titleKey: "settings.sections.sources.title", + icon: Layers, + }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, { diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 1e2089d1b..8a44aa099 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -99,25 +99,15 @@ const DEVICE_TYPE_LABEL_KEYS: Record = { }; /** - * v1.4.34 IW-D — section presentation mode. - * - `"standalone"` (default) renders the page-level header (h1 + - * subtitle) and keeps the section's own aria-labelledby wiring. - * - `"embedded"` drops the header because a parent surface - * (`` on `/settings/thresholds`) already names - * the page. The section becomes a `
` instead of a - * `
` so the assistive-tech reading order stays one - * combined surface. + * `` — route-level wrapper for `/settings/sources` + * (the "Sources" / "Quellen" page). * - * `/settings/sources` no longer renders this component directly — it - * permanently redirects to `/settings/thresholds`, which uses the - * `embedded` mode. The default keeps backward compatibility for any - * test/storybook that still mounts `` standalone. + * v1.8.7.1 — Sources is its own settings page again (it was merged into + * the combined "Targets & Sources" page under `/settings/thresholds` in + * v1.4.34 IW-D). The section renders its own page header (h1 + subtitle) + * and owns the per-metric source-priority + device-type ladders. */ -export interface SourcesSectionProps { - mode?: "standalone" | "embedded"; -} - -export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) { +export function SourcesSection() { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -292,32 +282,22 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {} return Object.keys(priority.deviceTypePriority).length; }, [priority]); - // v1.4.34 IW-D — `embedded` mode skips the section header because the - // parent surface (``) already names the page. - const isStandalone = mode === "standalone"; - const Wrapper = isStandalone ? "section" : "div"; - const wrapperProps = isStandalone - ? { - "aria-labelledby": "settings-section-sources-title", - className: "space-y-6", - } - : { className: "space-y-6" }; - return ( - - {isStandalone && ( -
-

- {t("settings.sections.sources.title")} -

-

- {t("settings.sections.sources.description")} -

-
- )} +
+
+

+ {t("settings.sections.sources.title")} +

+

+ {t("settings.sections.sources.description")} +

+
@@ -599,7 +579,7 @@ export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}
)}
- +
); } diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index 096045b09..ae913008d 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -1,37 +1,24 @@ "use client"; -import { SourcesSection } from "@/components/settings/sources-section"; import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section"; import { useTranslations } from "@/lib/i18n/context"; /** - * `` — route-level wrapper for `/settings/thresholds`. + * `` — route-level wrapper for `/settings/thresholds` + * (the "Targets" / "Zielwerte" page). * - * v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets & - * Sources" and absorbs the former `/settings/sources` content. The two - * editors stay distinct mutation flows (`/api/user/thresholds` for - * ranges + `/api/auth/me/source-priority` for source ladders) so a save - * on one half never disturbs the other; presenting them on a single - * page collapses two adjacent sidebar slots into one and matches the - * Settings nav audit (§7.1 item 3 in - * `.planning/round-v1433-audit-menu.md` — both screens were per-metric - * configuration shelves and reading them together matches how a user - * actually thinks about a metric: "for this metric, which sources do - * I trust AND what range counts as healthy?"). - * - * `/settings/sources` stays alive as a `permanentRedirect` (see - * `src/app/settings/sources/page.tsx`) so external bookmarks (iOS - * Settings deep-links, docs) keep resolving. + * v1.8.7.1 — Targets and Sources are two separate settings pages again. + * This page owns only the per-metric target-range editor + * (`/api/user/thresholds`); the source-priority ladders moved back onto + * their own `/settings/sources` page (``). The two were + * merged into one "Targets & Sources" page in v1.4.34 IW-D, but the + * combined surface ran long and the two editors drive distinct mutation + * flows, so each concern is self-contained on its own page. * * Visual structure on this page: - * 1. Section header (combined title + description). - * 2. `` — per-metric range inputs (top). - * The editor card kept its own affordance row (icon, reset-all - * button) so the action surface stays compact. - * 3. `` body — per-metric source priority + the - * two-axis device-type expander (bottom). The inner section's - * header was suppressed in `mode="embedded"` because the page - * header above already names the surface. + * 1. Section header (title + description). + * 2. `` — per-metric range inputs. The editor + * card keeps its own affordance row (icon, reset-all button). * * v1.4.16 phase B6: file renamed from the historic * `thresholds-settings-section.tsx` (``) @@ -63,15 +50,8 @@ export function ThresholdsSection() {

- {/* Per-metric threshold ranges — top card. */} + {/* Per-metric threshold ranges. */} - - {/* Per-metric source priority + two-axis device-type ladder — - bottom card. `mode="embedded"` suppresses the inner h1 + - subtitle (the page header above owns the surface name) and - drops the inner `
` aria-label so the assistive-tech - reading order stays one combined surface, not two. */} -
); } From 5827ef58cb45fcd97a9d3310d8a1c3dd81bee321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:32:30 +0200 Subject: [PATCH 08/17] refactor(insights): drop the unused metric-to-insight key helper The identity-cast helper had no caller and tripped the dead-code gate. Remove it and its now-orphaned type import. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/insights/metric-status-registry.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index 122648448..9484de5cb 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -36,7 +36,6 @@ * collect. */ import type { MeasurementType } from "@/generated/prisma/client"; -import type { InsightMetric } from "@/lib/insights/metric-availability"; /** The five shared archetypes plus the dedicated sleep template. */ export type MetricArchetype = @@ -435,17 +434,3 @@ export function metricStatusScope( ): `metric:${MetricStatusMetricId}` { return `metric:${metric}`; } - -/** - * Map a generic metric id to its `InsightMetric` key so the shared - * `hasMetricData` gate can answer the data-availability question with no - * new branch. The two vocabularies line up one-to-one for every - * registered metric except `STEPS` / `ACTIVE_ENERGY`, whose - * `InsightMetric` keys match the id directly (the divergence is only at - * the `MeasurementType` layer, which `hasMetricData` does not consult). - */ -export function metricStatusInsightKey( - metric: MetricStatusMetricId, -): InsightMetric { - return metric as InsightMetric; -} From 58b793ee4aaeabaaf07852a91c2aebb736c09c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:49:45 +0200 Subject: [PATCH 09/17] fix(insights): correct the active-energy assessment id and share the status card The active-energy page passed `ACTIVE_ENERGY_BURNED` as `statusMetric`, but that is the DB MeasurementType, not the registry id the metric-status route accepts. The route's closed enum only knows `ACTIVE_ENERGY`, so every fetch 422'd and the assessment card silently fell back to its empty body. Point the page at the `ACTIVE_ENERGY` registry id and type `statusMetric` (plus the `useInsightMetricStatus` metric param) to the closed `MetricStatusMetricId` union instead of a bare string, so a future MeasurementType-vs-registry-id mismatch is a compile error rather than a silent 422. A typecheck pass over all 29 pages confirms active-energy was the only diverging one. Extract a shared `MetricStatusCard` that owns the `useInsightMetricStatus` hook and the assessment-card wiring, collapsing the duplicated mount block in the HealthKit metric scaffold and the bespoke sleep page to a single line each (only the icon differs). Behaviour is unchanged: the card mounts only on the data-bearing branch, so a source-less account never fires an assessment round-trip. Add a test asserting the active-energy page wires an accepted registry id. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 6e371aaeb9698f7b4251c22e1d8f381ddfa70a00) --- src/app/insights/active-energy/page.tsx | 2 +- src/app/insights/sleep/page.tsx | 30 +++-------- .../healthkit-metric-page-status.test.tsx | 34 ++++++++++-- .../insights/healthkit-metric-page.tsx | 31 +++++------ .../insights/metric-status-card.tsx | 52 +++++++++++++++++++ src/hooks/use-insight-status.ts | 11 +++- 6 files changed, 112 insertions(+), 48 deletions(-) create mode 100644 src/components/insights/metric-status-card.tsx diff --git a/src/app/insights/active-energy/page.tsx b/src/app/insights/active-energy/page.tsx index de9a57fd9..b5b126fe8 100644 --- a/src/app/insights/active-energy/page.tsx +++ b/src/app/insights/active-energy/page.tsx @@ -17,7 +17,7 @@ export default function InsightsActiveEnergyPage() { return ( ` + owns the hook + card; only the icon differs from the HealthKit + scaffold (Moon vs Sparkles). The card mounts only on this + data-bearing branch, so a source-less account never fetches. */} - } - text={status?.text ?? null} - hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} - updatedAt={status?.updatedAt ?? null} - loading={isStatusLoading} - preparing={status?.preparing ?? false} + enabled={!isEmpty} /> ); diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index 9a1b9b5a2..d03212622 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -50,6 +50,8 @@ vi.mock("@/hooks/use-insight-status", async (importOriginal) => { }); import { HealthKitMetricPage } from "../healthkit-metric-page"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import InsightsActiveEnergyPage from "@/app/insights/active-energy/page"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { const queryClient = new QueryClient({ @@ -116,9 +118,10 @@ describe(" status mount", () => { const html = render(); - // No assessment heading, and the generic fetch is disabled. + // No assessment heading, and the card (which owns the hook) never + // mounts, so the generic fetch is never wired. expect(html).not.toContain("Assessment"); - expect(metricStatusMock).toHaveBeenCalledWith("", false); + expect(metricStatusMock).not.toHaveBeenCalled(); }); it("renders the insufficient-data empty state (no card) when the metric has no data", () => { @@ -132,9 +135,30 @@ describe(" status mount", () => { />, ); - // Empty branch: no settled assessment text, and the status fetch is - // disabled so a source-less account never fires the round-trip. + // Empty branch: the page short-circuits to the empty state before the + // card JSX, so no settled assessment text and the card (and its fetch) + // never mounts — a source-less account never fires the round-trip. expect(html).not.toContain("Your HRV has trended upward"); - expect(metricStatusMock).toHaveBeenCalledWith("HEART_RATE_VARIABILITY", false); + expect(metricStatusMock).not.toHaveBeenCalled(); + }); + + it("wires the active-energy page to an accepted registry id (not the MeasurementType remap)", () => { + // v1.8.7.1 (Design H1) — the active-energy page formerly passed + // `ACTIVE_ENERGY_BURNED` (the DB MeasurementType), which the route's + // closed enum rejects with 422; the registry id is `ACTIVE_ENERGY`. + // Render the real page module and assert the hook receives a metric id + // the route accepts. + analyticsMock.mockReturnValue({ + data: { summaries: { ACTIVE_ENERGY_BURNED: { count: 30 } } }, + isEmpty: false, + }); + metricStatusMock.mockReturnValue({ data: undefined, isLoading: false }); + + render(); + + expect(metricStatusMock).toHaveBeenCalled(); + const passedMetric = metricStatusMock.mock.calls[0][0] as string; + expect(passedMetric).toBe("ACTIVE_ENERGY"); + expect(METRIC_STATUS_IDS).toContain(passedMetric); }); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 929b9b17e..dcbe22f2f 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -6,15 +6,15 @@ import type { ComponentProps, ReactNode } from "react"; import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; -import { useInsightMetricStatus } from "@/hooks/use-insight-status"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useTranslations } from "@/lib/i18n/context"; import type { InsightMetric } from "@/lib/insights/metric-availability"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricStatusCard } from "@/components/insights/metric-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; @@ -109,8 +109,14 @@ export interface HealthKitMetricPageProps { * data-bearing branch; the empty (insufficient-data) branch keeps the * existing `` note and never fires an assessment * fetch. Omit it for metrics that should not carry an assessment. + * + * Typed to the closed `MetricStatusMetricId` union (the registry-id + * vocabulary the route's Zod enum accepts) rather than a bare string, + * so a page that passes a MeasurementType remap (e.g. + * `ACTIVE_ENERGY_BURNED` instead of the `ACTIVE_ENERGY` registry id) + * is a compile error, not a silent 422. */ - statusMetric?: string; + statusMetric?: MetricStatusMetricId; } export function HealthKitMetricPage({ @@ -136,14 +142,6 @@ export function HealthKitMetricPage({ const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); - // v1.8.7.1 — generic per-metric assessment. The hook must run on every - // render (rules of hooks), so it is always called; `enabled` gates the - // actual fetch on a configured `statusMetric` AND the presence of data - // so an insufficient-data page never fires an assessment round-trip. - const { data: status, isLoading: isStatusLoading } = useInsightMetricStatus( - statusMetric ?? "", - Boolean(statusMetric) && !isEmpty, - ); const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED @@ -225,15 +223,10 @@ export function HealthKitMetricPage({ ) : null} {statusMetric ? ( - } - text={status?.text ?? null} - hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} - updatedAt={status?.updatedAt ?? null} - loading={isStatusLoading} - preparing={status?.preparing ?? false} + enabled={!isEmpty} /> ) : null} diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx new file mode 100644 index 000000000..4634d6ba6 --- /dev/null +++ b/src/components/insights/metric-status-card.tsx @@ -0,0 +1,52 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useInsightMetricStatus } from "@/hooks/use-insight-status"; +import { useTranslations } from "@/lib/i18n/context"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; + +interface MetricStatusCardProps { + /** The registry metric id the generic assessment route keys on. */ + metric: MetricStatusMetricId; + /** The leading glyph (Sparkles for the generic scaffold, Moon for sleep). */ + icon: ReactNode; + /** + * Gate the fetch on the page having data, so an insufficient-data page + * never fires an assessment round-trip. The hook still runs on every + * render (rules of hooks); `enabled` decides whether it talks to the + * network. + */ + enabled: boolean; +} + +/** + * v1.8.7.1 — shared mount for the generic per-metric assessment card. + * + * Owns the `useInsightMetricStatus` hook + the eight-prop `` + * wiring (`status?.x ?? default`) so the HealthKit metric scaffold and the + * bespoke sleep page consume one seam instead of repeating the block. Only + * the icon differs between the two sites; everything else is identical. + */ +export function MetricStatusCard({ + metric, + icon, + enabled, +}: MetricStatusCardProps) { + const { t } = useTranslations(); + const { data: status, isLoading } = useInsightMetricStatus(metric, enabled); + + return ( + + ); +} diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 0ac0bc3ef..710aba305 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { queryKeys } from "@/lib/query-keys"; /** @@ -183,7 +184,15 @@ export function useInsightStatus(metric: InsightStatusMetric) { * no data (the page renders the insufficient-data empty state instead of * the card), so a brand-new account never fires an assessment round-trip. */ -export function useInsightMetricStatus(metric: string, enabled = true) { +export function useInsightMetricStatus( + // The closed registry-id union (the route's Zod enum vocabulary). `""` + // is the disabled-fetch placeholder the `HealthKitMetricPage` passes when + // no `statusMetric` is wired (the hook must still run per the rules of + // hooks); `enabled` is false on that branch so the empty id never reaches + // a round-trip. A typo'd metric id is therefore a compile error. + metric: MetricStatusMetricId | "", + enabled = true, +) { const { isAuthenticated } = useAuth(); const { locale } = useTranslations(); From bd8a4e7990d9371f230af6835de11be5c4199f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:49:52 +0200 Subject: [PATCH 10/17] refactor(insights): type-check the generic metric previous-context scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic metric-status generator read its previous-context row through an `as never` cast, because `getPreviousInsightContext` was typed to the seven specialised scopes only. Widen the helper's `scope` param to a `PreviousContextScope` union that also accepts the `metric:-status` scope form, so the generic call type-checks honestly instead of silently disabling type checking on the argument. No behaviour change — the helper only interpolates the scope into the audit action. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 899d59b6fa219a14ba39eacded2e042f0f95c2c5) --- src/lib/insights/memory.ts | 12 +++++++++++- src/lib/insights/metric-status.ts | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/lib/insights/memory.ts b/src/lib/insights/memory.ts index b9277dd69..450c74df4 100644 --- a/src/lib/insights/memory.ts +++ b/src/lib/insights/memory.ts @@ -33,6 +33,16 @@ export type InsightScope = | "medication-compliance-status" | "mood-status"; +/** + * v1.8.7.1 — the generic per-HealthKit-metric assessments key their + * previous-context rows under `insights.metric:-status.`, the + * same `-status` action shape the seven specialised scopes use. Accept + * that scope form alongside the seven so the generic generator can read + * its comparison row without an `as never` cast that would silently + * disable type checking on the argument. + */ +export type PreviousContextScope = InsightScope | `metric:${string}-status`; + export interface PreviousInsightContext { /** ISO timestamp the previous analysis was rendered. */ generatedAt: string; @@ -59,7 +69,7 @@ export interface PreviousInsightContext { */ export async function getPreviousInsightContext( userId: string, - scope: InsightScope, + scope: PreviousContextScope, locale: "de" | "en", minAgeHours: number = 12, ): Promise { diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 88a8d30dc..855d4a18c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -280,8 +280,10 @@ export async function generateMetricStatus(args: { args.userId, // memory.ts keys previous context by the `insights..` // action; the generic scope (`metric:-status`) carries the same - // `-status` suffix so the comparison row reads back correctly. - `${scope}-status` as never, + // `-status` suffix so the comparison row reads back correctly. The + // `getPreviousInsightContext` param is typed to accept this scope form + // (`PreviousContextScope`), so the argument type-checks honestly. + `${scope}-status`, locale, 12, ); From 746e00210472b93237f9d83e7290dcc7d2a1eded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:50:01 +0200 Subject: [PATCH 11/17] fix(insights): re-warm generic metric assessments on measurement ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ingest invalidation path mapped a fresh measurement only to the seven specialised assessment scopes, so a freshly-synced HealthKit metric's generic assessment card lagged its real-time data until the nightly warm pass — an asymmetry against the near-real-time specialised cards. Add a reverse `MeasurementType → metric id` index to the registry and extend `invalidateStatusInsightsForTypes` so a reading of a type that backs a registered generic metric also evicts and re-enqueues the matching `metric:` scope, using the same single-locale, singleton-debounced enqueue as the specialised scopes. Unregistered types and the seven specialised metrics resolve to no generic scope, so the constant sync cannot fan out to unwanted cards. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 9bc0f116b46950c8ffa79d127d5dcce32ea644a4) --- .../invalidate-status-insights.test.ts | 54 ++++++++++++++++--- src/lib/insights/comprehensive-generate.ts | 37 +++++++++++-- src/lib/insights/metric-status-registry.ts | 30 +++++++++++ 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts index 9fe03980b..c6c38c9e8 100644 --- a/src/lib/insights/__tests__/invalidate-status-insights.test.ts +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -69,17 +69,27 @@ describe("invalidateStatusInsightsForTypes", () => { expect(deletedScopes()).toEqual(["blood-pressure", "general"]); }); - it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => { + it("maps PULSE and RESTING_HEART_RATE to pulse + general (+ the resting-HR generic scope)", async () => { await invalidateStatusInsightsForTypes("u1", [ "PULSE", "RESTING_HEART_RATE", ]); - expect(deletedScopes()).toEqual(["general", "pulse"]); + // PULSE feeds the specialised pulse + general scopes; RESTING_HEART_RATE + // feeds those too AND carries its own generic assessment card + // (`metric:RESTING_HEART_RATE`), so all three are dirtied. + expect(deletedScopes()).toEqual([ + "general", + "metric:RESTING_HEART_RATE", + "pulse", + ]); }); - it("maps an unmapped metric to general only", async () => { + it("maps a metric with no specialised scope to general + its generic scope", async () => { + // BLOOD_GLUCOSE has no specialised assessment, but v1.8.7.1 gives it a + // generic `metric:BLOOD_GLUCOSE` card, so a fresh reading dirties the + // general overview AND that generic scope. await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); - expect(deletedScopes()).toEqual(["general"]); + expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); }); it("dedupes scopes across a mixed batch", async () => { @@ -88,10 +98,12 @@ describe("invalidateStatusInsightsForTypes", () => { "PULSE", "BLOOD_GLUCOSE", ]); - // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general + // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ + // general + metric:BLOOD_GLUCOSE (BLOOD_GLUCOSE) expect(deletedScopes()).toEqual([ "bmi", "general", + "metric:BLOOD_GLUCOSE", "pulse", "weight", ]); @@ -133,7 +145,35 @@ describe("invalidateStatusInsightsForTypes", () => { // pulse / bp scopes — the constant Apple-Health sync must not thrash // every card's cache. await invalidateStatusInsightsForTypes("u1", ["ACTIVITY_STEPS"]); - expect(deletedScopes()).toEqual(["general"]); - expect(enqueuedScopes()).toEqual(["general"]); + expect(deletedScopes()).toEqual(["general", "metric:STEPS"]); + expect(enqueuedScopes()).toEqual(["general", "metric:STEPS"]); + }); + + it("re-warms the matching generic metric scope for a registered type", async () => { + // v1.8.7.1 — a blood-glucose sample dirties the general overview AND + // its own generic assessment card (`metric:BLOOD_GLUCOSE`), so the + // freshly-synced metric's card refreshes in the background instead of + // lagging until the nightly warm pass. + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + expect(deletedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); + expect(enqueuedScopes()).toEqual(["general", "metric:BLOOD_GLUCOSE"]); + }); + + it("maps a remapped MeasurementType to its registry metric id", async () => { + // ACTIVE_ENERGY_BURNED is stored under that DB type but the generic + // metric id (route param + cache scope) is ACTIVE_ENERGY. + await invalidateStatusInsightsForTypes("u1", ["ACTIVE_ENERGY_BURNED"]); + expect(enqueuedScopes()).toEqual(["general", "metric:ACTIVE_ENERGY"]); + }); + + it("does not emit a generic scope for an unregistered type", async () => { + // A weight sample feeds weight + bmi + general; WEIGHT is one of the + // seven specialised metrics and has no generic registry entry, so no + // `metric:` scope is enqueued. + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]); + expect( + enqueuedScopes().some((s) => s.startsWith("metric:")), + ).toBe(false); }); }); diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index dd5203b33..aaa54229b 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -49,6 +49,10 @@ import { import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared"; import { normalizeLocale } from "@/lib/insights/status-shared"; +import { + metricIdForMeasurementType, + metricStatusScope, +} from "@/lib/insights/metric-status-registry"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -218,19 +222,37 @@ export async function invalidateStatusInsightsForTypes( types: Iterable, ): Promise { const scopes = new Set(); + // v1.8.7.1 — a fresh measurement of a type that backs a generic + // HealthKit assessment card (the ~30 registry metrics) also dirties that + // card's `metric:` scope. Collect those alongside the seven + // specialised scopes so the generic tier is invalidated + re-warmed on + // ingest with the same debounced enqueue, rather than lagging until the + // nightly cron. Only registered, data-bearing types map here; the seven + // specialised metrics and any unregistered type resolve to null and are + // skipped, so the constant sync cannot fan out to unwanted scopes. + const metricScopes = new Set<`metric:${string}`>(); for (const type of types) { for (const scope of statusScopesForMeasurementType(type)) { scopes.add(scope); } + const metricId = metricIdForMeasurementType(type); + if (metricId) { + metricScopes.add(metricStatusScope(metricId)); + } } - if (scopes.size === 0) return; + if (scopes.size === 0 && metricScopes.size === 0) return; await prisma.auditLog.deleteMany({ where: { userId, - OR: Array.from(scopes, (scope) => ({ - action: { startsWith: `insights.${scope}-status.` }, - })), + OR: [ + ...Array.from(scopes, (scope) => ({ + action: { startsWith: `insights.${scope}-status.` }, + })), + ...Array.from(metricScopes, (scope) => ({ + action: { startsWith: `insights.${scope}-status.` }, + })), + ], }, }); @@ -258,6 +280,13 @@ export async function invalidateStatusInsightsForTypes( for (const scope of scopes) { void enqueueStatusGeneration({ userId, metric: scope, locale }); } + // v1.8.7.1 — re-warm the matching generic metric scopes the same way, so + // a freshly-synced HealthKit metric's assessment card refreshes in the + // background instead of lagging until the nightly warm pass. Same + // single-locale, singleton-debounced enqueue as the seven scopes above. + for (const scope of metricScopes) { + void enqueueStatusGeneration({ userId, metric: scope, locale }); + } } interface GenerateOptions { diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index 9484de5cb..eac071d10 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -421,6 +421,36 @@ export function getMetricStatusMeta( return isMetricStatusId(metric) ? REGISTRY[metric] : null; } +/** + * Reverse index: the DB `MeasurementType` → the generic metric id it backs + * (built once at module load). A handful of types map under a different id + * than their literal name (`ACTIVITY_STEPS` → `STEPS`, + * `ACTIVE_ENERGY_BURNED` → `ACTIVE_ENERGY`); every other registered type + * is its own id. Types with no registry entry (the seven specialised + * metrics, WORKOUTS, …) are simply absent. + */ +const MEASUREMENT_TYPE_TO_METRIC_ID = new Map< + MeasurementType, + MetricStatusMetricId +>( + (Object.values(REGISTRY) as MetricStatusMeta[]).map((meta) => [ + meta.measurementType, + meta.id, + ]), +); + +/** + * Resolve the generic metric id a `MeasurementType` feeds, or null when + * the type carries no generic assessment card. Used by the ingest + * invalidation path to re-warm the matching `metric:` scope when a + * fresh measurement of a data-bearing type lands. + */ +export function metricIdForMeasurementType( + type: MeasurementType, +): MetricStatusMetricId | null { + return MEASUREMENT_TYPE_TO_METRIC_ID.get(type) ?? null; +} + /** * The cache/scope id for a metric assessment. The `metric:` prefix keeps * the generic scopes disjoint from the seven bare specialised scope From 4364f3b509e89f5416d190f3911ad8bc8366a1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:50:08 +0200 Subject: [PATCH 12/17] i18n(settings): translate the source-priority editor body for es/fr/it/pl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting Sources back out into a standalone settings page re-exposed the source-priority editor body — card title, help text, reorder buttons, the move-up / move-down ARIA labels, per-metric and device-type controls — which were still English placeholders for es/fr/it/pl. Provide natural translations so a non-de/en screen-reader user no longer hears English. Drop the orphaned `settings.sections.sources.redirectNotice` key from all six bundles; the redirect it captioned was deleted when Sources became a real route. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 945d3ed8a6981c23dc51f45c22a1cd3f2625c859) --- messages/de.json | 1 - messages/en.json | 1 - messages/es.json | 59 ++++++++++++++++++++++++------------------------ messages/fr.json | 57 +++++++++++++++++++++++----------------------- messages/it.json | 57 +++++++++++++++++++++++----------------------- messages/pl.json | 57 +++++++++++++++++++++++----------------------- 6 files changed, 113 insertions(+), 119 deletions(-) diff --git a/messages/de.json b/messages/de.json index 911d740e2..c018aab94 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2638,7 +2638,6 @@ "sources": { "title": "Quellen", "description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.", - "redirectNotice": "Quellen sind jetzt Teil von „Zielwerte & Quellen“.", "cardTitle": "Quellen-Priorität pro Metrik", "help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.", "resetDefaults": "Auf Standard zurücksetzen", diff --git a/messages/en.json b/messages/en.json index fdcd403c2..9019923b6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2638,7 +2638,6 @@ "sources": { "title": "Sources", "description": "Pick which ingest source wins when more than one records the same metric for the same day.", - "redirectNotice": "Sources are now part of “Targets & Sources”.", "cardTitle": "Source priority by metric", "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", "resetDefaults": "Reset to defaults", diff --git a/messages/es.json b/messages/es.json index e719fe090..101db930e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2638,52 +2638,51 @@ "sources": { "title": "Fuentes", "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", - "redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».", - "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", - "resetDefaults": "Reset to defaults", - "moveUp": "Move up", - "moveDown": "Move down", - "saveSuccess": "Source priority saved.", - "saveError": "Saving the source priority failed.", - "resetSuccess": "Source priority reset to defaults.", - "perMetricToggle": "Per-metric overrides ({count})", - "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", - "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", + "cardTitle": "Prioridad de fuentes por métrica", + "help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.", + "resetDefaults": "Restablecer valores predeterminados", + "moveUp": "Subir", + "moveDown": "Bajar", + "saveSuccess": "Prioridad de fuentes guardada.", + "saveError": "No se pudo guardar la prioridad de fuentes.", + "resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.", + "perMetricToggle": "Ajustes por métrica ({count})", + "perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.", + "perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.", "resetMetric": "Restablecer", - "deviceTypeToggle": "Device-type priority ({count})", - "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", - "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "deviceTypeToggle": "Prioridad por tipo de dispositivo ({count})", + "deviceTypeHelp": "Dentro de una fuente, qué dispositivo prevalece. Cuando un Apple Watch y un iPhone registran ambos los pasos, el tipo de dispositivo mejor clasificado cuenta como la lectura canónica.", + "deviceTypeDefault": "Orden predeterminado por tipo de dispositivo", + "resetDeviceTypes": "Restablecer tipos de dispositivo", "metrics": { "steps": "Pasos", - "activeEnergy": "Active energy", - "walkingRunningDistance": "Walking + running distance", - "flightsClimbed": "Flights climbed", + "activeEnergy": "Energía activa", + "walkingRunningDistance": "Distancia a pie + corriendo", + "flightsClimbed": "Pisos subidos", "sleep": "Sueño", "weight": "Peso", "bloodPressure": "Presión arterial", "pulse": "Pulso", "bodyFat": "Grasa corporal", - "bodyTemperature": "Body temperature", - "spo2": "Oxygen saturation", - "hrv": "Heart-rate variability", + "bodyTemperature": "Temperatura corporal", + "spo2": "Saturación de oxígeno", + "hrv": "Variabilidad de la frecuencia cardíaca", "restingHeartRate": "Frecuencia cardíaca en reposo", - "vo2Max": "VO2 max" + "vo2Max": "VO2 máx." }, "sourceLabels": { "WITHINGS": "Withings", "APPLE_HEALTH": "Apple Health", - "MANUAL": "Manual entry", + "MANUAL": "Entrada manual", "IMPORT": "Importar" }, "deviceLabels": { - "watch": "Watch", - "band": "Band", - "ring": "Ring", - "phone": "Phone", - "scale": "Scale", - "other": "Other", + "watch": "Reloj", + "band": "Pulsera", + "ring": "Anillo", + "phone": "Teléfono", + "scale": "Báscula", + "other": "Otro", "unknown": "Desconocido" } }, diff --git a/messages/fr.json b/messages/fr.json index 5904ceff4..d4b64818c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2638,52 +2638,51 @@ "sources": { "title": "Sources", "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", - "redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».", - "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", - "resetDefaults": "Reset to defaults", - "moveUp": "Move up", - "moveDown": "Move down", - "saveSuccess": "Source priority saved.", - "saveError": "Saving the source priority failed.", - "resetSuccess": "Source priority reset to defaults.", - "perMetricToggle": "Per-metric overrides ({count})", - "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", - "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", + "cardTitle": "Priorité des sources par métrique", + "help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.", + "resetDefaults": "Rétablir les valeurs par défaut", + "moveUp": "Monter", + "moveDown": "Descendre", + "saveSuccess": "Priorité des sources enregistrée.", + "saveError": "Échec de l’enregistrement de la priorité des sources.", + "resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.", + "perMetricToggle": "Réglages par métrique ({count})", + "perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.", + "perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.", "resetMetric": "Réinitialiser", - "deviceTypeToggle": "Device-type priority ({count})", - "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", - "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "deviceTypeToggle": "Priorité par type d’appareil ({count})", + "deviceTypeHelp": "Au sein d’une source, quel appareil l’emporte. Lorsqu’une Apple Watch et un iPhone enregistrent tous deux les pas, le type d’appareil le mieux classé compte comme la lecture canonique.", + "deviceTypeDefault": "Ordre par défaut des types d’appareil", + "resetDeviceTypes": "Réinitialiser les types d’appareil", "metrics": { "steps": "Pas", - "activeEnergy": "Active energy", - "walkingRunningDistance": "Walking + running distance", - "flightsClimbed": "Flights climbed", + "activeEnergy": "Énergie active", + "walkingRunningDistance": "Distance marche + course", + "flightsClimbed": "Étages gravis", "sleep": "Sommeil", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", "bodyFat": "Masse grasse", - "bodyTemperature": "Body temperature", - "spo2": "Oxygen saturation", - "hrv": "Heart-rate variability", + "bodyTemperature": "Température corporelle", + "spo2": "Saturation en oxygène", + "hrv": "Variabilité de la fréquence cardiaque", "restingHeartRate": "Fréquence cardiaque au repos", "vo2Max": "VO2 max" }, "sourceLabels": { "WITHINGS": "Withings", "APPLE_HEALTH": "Apple Health", - "MANUAL": "Manual entry", + "MANUAL": "Saisie manuelle", "IMPORT": "Importer" }, "deviceLabels": { - "watch": "Watch", - "band": "Band", - "ring": "Ring", - "phone": "Phone", - "scale": "Scale", - "other": "Other", + "watch": "Montre", + "band": "Bracelet", + "ring": "Bague", + "phone": "Téléphone", + "scale": "Balance", + "other": "Autre", "unknown": "Inconnu" } }, diff --git a/messages/it.json b/messages/it.json index 2d58c19a3..72861adbc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2638,52 +2638,51 @@ "sources": { "title": "Fonti", "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", - "redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».", - "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", - "resetDefaults": "Reset to defaults", - "moveUp": "Move up", - "moveDown": "Move down", - "saveSuccess": "Source priority saved.", - "saveError": "Saving the source priority failed.", - "resetSuccess": "Source priority reset to defaults.", - "perMetricToggle": "Per-metric overrides ({count})", - "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", - "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", + "cardTitle": "Priorità delle fonti per metrica", + "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", + "resetDefaults": "Ripristina i valori predefiniti", + "moveUp": "Sposta su", + "moveDown": "Sposta giù", + "saveSuccess": "Priorità delle fonti salvata.", + "saveError": "Salvataggio della priorità delle fonti non riuscito.", + "resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.", + "perMetricToggle": "Personalizzazioni per metrica ({count})", + "perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.", + "perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.", "resetMetric": "Reimposta", - "deviceTypeToggle": "Device-type priority ({count})", - "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", - "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "deviceTypeToggle": "Priorità per tipo di dispositivo ({count})", + "deviceTypeHelp": "All’interno di una fonte, quale dispositivo prevale. Quando un Apple Watch e un iPhone registrano entrambi i passi, il tipo di dispositivo più in alto conta come lettura canonica.", + "deviceTypeDefault": "Ordine predefinito per tipo di dispositivo", + "resetDeviceTypes": "Reimposta i tipi di dispositivo", "metrics": { "steps": "Passi", - "activeEnergy": "Active energy", - "walkingRunningDistance": "Walking + running distance", - "flightsClimbed": "Flights climbed", + "activeEnergy": "Energia attiva", + "walkingRunningDistance": "Distanza a piedi + corsa", + "flightsClimbed": "Piani saliti", "sleep": "Sonno", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Polso", "bodyFat": "Massa grassa", - "bodyTemperature": "Body temperature", - "spo2": "Oxygen saturation", - "hrv": "Heart-rate variability", + "bodyTemperature": "Temperatura corporea", + "spo2": "Saturazione di ossigeno", + "hrv": "Variabilità della frequenza cardiaca", "restingHeartRate": "Frequenza cardiaca a riposo", "vo2Max": "VO2 max" }, "sourceLabels": { "WITHINGS": "Withings", "APPLE_HEALTH": "Apple Health", - "MANUAL": "Manual entry", + "MANUAL": "Inserimento manuale", "IMPORT": "Importa" }, "deviceLabels": { - "watch": "Watch", - "band": "Band", - "ring": "Ring", - "phone": "Phone", - "scale": "Scale", - "other": "Other", + "watch": "Orologio", + "band": "Bracciale", + "ring": "Anello", + "phone": "Telefono", + "scale": "Bilancia", + "other": "Altro", "unknown": "Sconosciuto" } }, diff --git a/messages/pl.json b/messages/pl.json index 7030787dd..308ed202d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2638,52 +2638,51 @@ "sources": { "title": "Źródła", "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", - "redirectNotice": "Źródła są teraz częścią „Cele i źródła”.", - "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", - "resetDefaults": "Reset to defaults", - "moveUp": "Move up", - "moveDown": "Move down", - "saveSuccess": "Source priority saved.", - "saveError": "Saving the source priority failed.", - "resetSuccess": "Source priority reset to defaults.", - "perMetricToggle": "Per-metric overrides ({count})", - "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", - "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", + "cardTitle": "Priorytet źródeł według metryki", + "help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.", + "resetDefaults": "Przywróć wartości domyślne", + "moveUp": "W górę", + "moveDown": "W dół", + "saveSuccess": "Zapisano priorytet źródeł.", + "saveError": "Zapisanie priorytetu źródeł nie powiodło się.", + "resetSuccess": "Przywrócono domyślny priorytet źródeł.", + "perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})", + "perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.", + "perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.", "resetMetric": "Resetuj", - "deviceTypeToggle": "Device-type priority ({count})", - "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", - "deviceTypeDefault": "Default device-type ladder", - "resetDeviceTypes": "Reset device-types", + "deviceTypeToggle": "Priorytet według typu urządzenia ({count})", + "deviceTypeHelp": "W obrębie źródła decyduje, które urządzenie wygrywa. Gdy Apple Watch i iPhone rejestrują kroki, wyżej sklasyfikowany typ urządzenia liczy się jako odczyt kanoniczny.", + "deviceTypeDefault": "Domyślna kolejność typów urządzeń", + "resetDeviceTypes": "Resetuj typy urządzeń", "metrics": { "steps": "Kroki", - "activeEnergy": "Active energy", - "walkingRunningDistance": "Walking + running distance", - "flightsClimbed": "Flights climbed", + "activeEnergy": "Aktywna energia", + "walkingRunningDistance": "Dystans marszu + biegu", + "flightsClimbed": "Pokonane piętra", "sleep": "Sen", "weight": "Waga", "bloodPressure": "Ciśnienie krwi", "pulse": "Tętno", "bodyFat": "Tkanka tłuszczowa", - "bodyTemperature": "Body temperature", - "spo2": "Oxygen saturation", - "hrv": "Heart-rate variability", + "bodyTemperature": "Temperatura ciała", + "spo2": "Saturacja tlenem", + "hrv": "Zmienność rytmu serca", "restingHeartRate": "Tętno spoczynkowe", "vo2Max": "VO2 max" }, "sourceLabels": { "WITHINGS": "Withings", "APPLE_HEALTH": "Apple Health", - "MANUAL": "Manual entry", + "MANUAL": "Wpis ręczny", "IMPORT": "Importuj" }, "deviceLabels": { - "watch": "Watch", - "band": "Band", - "ring": "Ring", - "phone": "Phone", - "scale": "Scale", - "other": "Other", + "watch": "Zegarek", + "band": "Opaska", + "ring": "Pierścień", + "phone": "Telefon", + "scale": "Waga", + "other": "Inne", "unknown": "Nieznane" } }, From 2123c63af446625817c825ed338fddb8523d0696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 13:50:13 +0200 Subject: [PATCH 13/17] a11y(insights): hide the workouts back-link arrow from assistive tech The workouts detail back-link's `ArrowLeft` glyph carried no `aria-hidden`, so a screen reader announced it alongside the link text. Add `aria-hidden="true"`, matching the sibling values detail page's back-link. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit 108c3ff583a707b100570e2999b129055f90c76f) --- src/app/insights/workouts/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index f55155ff5..ec9dfe198 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -49,7 +49,7 @@ export default function InsightsWorkoutDetailPage({ className="-ml-2 w-fit" > - +