diff --git a/CHANGELOG.md b/CHANGELOG.md index db831e9a7..b4f02d984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [1.27.8] — 2026-07-05 — Dashboard follow-ups and design-system sweeps + +### Changed + +- The hero's medication ring now shows today's doses — "1/3 taken" as a filling ring — instead of a seven-day percentage. No doses scheduled today means no ring. +- Ring choices under Settings → Dashboard apply immediately when toggled and the selection can be reordered; the order carries over to the hero. Ring colours match the Insights rings exactly. +- The "daily briefing" heading on the dashboard is itself the link to the full briefing (no underline), the verdict sentence links to the metric it talks about when it has no action button, and the separate "open Insights" text link is gone. +- The light theme now tunes all remaining highlight colours to readable contrast on light cards — admin pages, the Coach thread, and confidence meters no longer render neon-on-white. +- Failed data loads show the same retry card everywhere; a dozen surfaces previously showed a bare error line without a retry. +- Insights tiles share one header anatomy (icon and title in the standard size and colour) — the sleep, glucose, cycle, and correlation tiles had drifted into hand-rolled variants. +- Card paddings across ~20 surfaces now come from the card primitive alone, removing stale per-card overrides from an older spacing era. + +### Added + +- The doctor report can include structured allergies and family history as toggleable sections, and the Coach considers both in its picture of you. +- The PHQ-9 check-in regained its optional closing question ("how difficult have these problems made daily life?") as a regular tenth question — answering is optional. +- A guard test pins the settings pages against the recurring scroll-past-the-end class, and the lint rule against raw palette colours now also catches theme utilities, colour props, and arbitrary values. + ## [1.27.7] — 2026-07-05 — Score rings on the dashboard ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d3626456a..88bc29ec8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.7 + version: 1.27.8 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -944,6 +944,10 @@ paths: type: boolean labs: type: boolean + allergies: + type: boolean + familyHistory: + type: boolean locale: type: string enum: @@ -1076,6 +1080,10 @@ paths: type: boolean labs: type: boolean + allergies: + type: boolean + familyHistory: + type: boolean resourceTypes: maxItems: 8 type: array @@ -21424,9 +21432,11 @@ components: scoreRings: description: "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like - `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green - / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or - a disabled module, never zero. Clients render what arrives and never recompute." + `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — + `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress + semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is + absent when no dose is scheduled today. Only rings with data appear, in selection order — a missing entry + means no data or a disabled module, never zero. Clients render what arrives and never recompute." type: array items: type: object @@ -21448,6 +21458,23 @@ components: - green - yellow - red + doses: + description: MED_COMPLIANCE only — today's dose tally behind the progress score, for a 'taken/scheduled' ring display + (e.g. 1/3). Absent on the derived score rings. + type: object + properties: + taken: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + scheduled: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - taken + - scheduled + additionalProperties: false required: - id - score diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts new file mode 100644 index 000000000..e9dd554d4 --- /dev/null +++ b/e2e/settings-overscroll.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Vertical over-scroll regression guard (the recurring class behind the + * v1.25.11 settings fix): the scroll viewport must never extend far past + * the last content element. The rule the guard pins is structural — only + * the AuthShell owns the scroll height and the bottom padding + * (`pt-6 pb-20` on the wrapper, plus the ``); a page/section must never add its own viewport-height + * reserve or bottom gutter, because every such nested reserve stacks on + * the shell's own budget and reopens the dark-band-below-the-last-card + * bug. + * + * The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content + * element + the shell-owned padding + tolerance. Runs across the + * settings routes (short hub, long sortable-list subpages, long form + * section) at desktop AND phone-shaped viewports, because the two shell + * paddings differ per breakpoint. + */ +const ROUTES = [ + "/settings/layout", + "/settings/layout/dashboard", + "/settings/layout/insights", + "/settings/account", + "/settings/notifications", +] as const; + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20 + { name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16 +] as const; + +test.describe("settings vertical over-scroll guard", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + }); + + for (const vp of VIEWPORTS) { + for (const route of ROUTES) { + test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + // Let entry transitions / late queries settle before measuring. + await page.waitForTimeout(500); + + const dims = await page.evaluate(() => { + const main = document.getElementById("main-content"); + if (!main) return null; + const wrapper = main.firstElementChild as HTMLElement | null; + if (!wrapper) return null; + // Lowest content edge: max bottom over the wrapper's visible, + // non-fixed descendants, in the scroll container's coordinate + // space. Elements inside nested scroll containers are clipped + // by their own overflow and never add page scroll height, so + // skip anything whose scrollable ancestor is not `main`. + let maxBottom = 0; + const mainTop = main.getBoundingClientRect().top; + for (const el of wrapper.querySelectorAll("*")) { + const cs = getComputedStyle(el); + if (cs.position === "fixed") continue; + const rect = el.getBoundingClientRect(); + if (rect.height === 0 && rect.width === 0) continue; + // Skip descendants of inner scroll containers (their + // overflow does not contribute to the page scroll height). + let p = el.parentElement; + let inner = false; + while (p && p !== main) { + const pcs = getComputedStyle(p); + if ( + (pcs.overflowY === "auto" || pcs.overflowY === "scroll") && + p.scrollHeight > p.clientHeight + ) { + inner = true; + break; + } + p = p.parentElement; + } + if (inner) continue; + const bottom = rect.bottom + main.scrollTop - mainTop; + if (bottom > maxBottom) maxBottom = bottom; + } + return { + scrollHeight: main.scrollHeight, + clientHeight: main.clientHeight, + contentBottom: Math.round(maxBottom), + }; + }); + + expect(dims, "main-content / wrapper present").not.toBeNull(); + if (!dims) return; + + // A page shorter than the viewport cannot over-scroll at all. + if (dims.scrollHeight <= dims.clientHeight) return; + + const tolerance = 24; + expect( + dims.scrollHeight, + `scrollHeight=${dims.scrollHeight} contentBottom=${dims.contentBottom} ` + + `expectedPad=${vp.expectedPad} — the scroll area extends past the ` + + `last content element + the shell-owned padding; some nested ` + + `min-h / bottom padding is stacking on the shell budget`, + ).toBeLessThanOrEqual(dims.contentBottom + vp.expectedPad + tolerance); + }); + } + } +}); diff --git a/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs new file mode 100644 index 000000000..b55629c45 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs @@ -0,0 +1,161 @@ +/** + * Unit tests for the `healthlog/no-raw-palette-color` rule — the four + * detectors (`palette`, `dracula`, `color-props`, `arbitrary-value`), the + * `checks` option that lets the flat config run them at split severities, + * and the documented exemptions (global-error.tsx, themeColor metadata, + * test files, `var(--token)` values). + * + * RuleTester needs a filename inside the enforced roots + * (`src/components/` / `src/app/`) — the rule is scope-gated. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-raw-palette-color.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const APP_FILE = "/repo/src/components/insights/example.tsx"; + +ruleTester.run("no-raw-palette-color (default checks)", rule, { + valid: [ + // Semantic tokens never carry a numeric shade. + { + code: 'const c = "text-warning bg-success/10 border-info";', + filename: APP_FILE, + }, + // QR quiet-zone style bare white/black stays legal. + { code: 'const c = "bg-white text-black";', filename: APP_FILE }, + // var(--token) is the sanctioned chart-colour carrier. + { code: '', filename: APP_FILE }, + { code: 'const cfg = { stroke: "var(--chart-1)" };', filename: APP_FILE }, + // Arbitrary values referencing a token are fine. + { code: 'const c = "stroke-[var(--warning)]";', filename: APP_FILE }, + // Non-colour props carrying a hash are not colour literals. + { code: '', filename: APP_FILE }, + // themeColor viewport metadata is exempt by ancestry. + { + code: 'export const viewport = { themeColor: [{ media: "(prefers-color-scheme: dark)", color: "#282a36" }] };', + filename: "/repo/src/app/layout.tsx", + }, + // Dracula utilities do NOT error under the default check set — they + // ride the separate warn-level registration. + { code: 'const c = "text-dracula-green";', filename: APP_FILE }, + // Outside the enforced roots nothing matches. + { code: 'const c = "text-amber-500";', filename: "/repo/src/lib/x.ts" }, + // The global error boundary renders without the token stylesheet. + { + code: 'const c = "text-amber-500";', + filename: "/repo/src/app/global-error.tsx", + }, + // Test files are exempt. + { + code: 'const c = "text-amber-500";', + filename: "/repo/src/components/__tests__/example.test.tsx", + }, + ], + invalid: [ + // (original) raw Tailwind stock palette utility. + { + code: 'const c = "text-amber-500";', + filename: APP_FILE, + errors: [{ messageId: "rawPalette" }], + }, + { + code: "const c = `bg-green-500/10 ${extra}`;", + filename: APP_FILE, + errors: [{ messageId: "rawPalette" }], + }, + // (b) colour-shaped JSX prop with a raw hex. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (b) colour-shaped object property (Recharts config / style objects). + { + code: 'const cfg = { fill: "#8b5cf6" };', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (b) hex embedded in a longer value (gradient) still trips. + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "colorProp" }], + }, + // (c) Tailwind arbitrary colour values. + { + code: 'const c = "bg-[#282a36]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + { + code: 'const c = "text-[oklch(0.7_0.1_300)]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + { + code: 'const c = "border-[rgb(40,42,54)]";', + filename: APP_FILE, + errors: [{ messageId: "arbitraryColor" }], + }, + ], +}); + +ruleTester.run("no-raw-palette-color (dracula check)", rule, { + valid: [ + // Semantic + token forms stay clean under the dracula-only check set. + { + code: 'const c = "text-success bg-warning/10 text-amber-500";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + }, + // dracula var() references in CSS-in-JS strings are not utilities. + { + code: 'const c = "stroke-[var(--dracula-purple)]";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + }, + ], + invalid: [ + { + code: 'const c = "text-dracula-green";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + { + code: 'const c = "bg-dracula-orange/15 border-dracula-purple";', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + { + code: 'const c = cn(isUp ? "text-dracula-cyan" : "text-muted-foreground");', + filename: APP_FILE, + options: [{ checks: ["dracula"] }], + errors: [{ messageId: "draculaUtility" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 1119c7d7b..57d105ca5 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -21,5 +21,10 @@ module.exports = { "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, "no-raw-palette-color": noRawPaletteColor, + // Same module as `no-raw-palette-color`, registered under a second + // name so the flat config can run the `dracula` check at a different + // severity (warn) than the error-level checks. See the staged plan in + // the rule header. + "no-dracula-utility": noRawPaletteColor, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js index 6fe38790a..3dfcffc40 100644 --- a/eslint-plugins/healthlog/no-raw-palette-color.js +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -1,6 +1,6 @@ /** - * @fileoverview ESLint rule — raw Tailwind palette color utilities are - * banned in app UI; route every tone through a semantic token. + * @fileoverview ESLint rule — raw colour literals are banned in app UI; + * route every tone through a semantic token. * * v1.26.0 — the design-consistency wave collapsed the app's colour * vocabulary onto semantic tokens (`text-warning` / `text-info` / @@ -12,18 +12,41 @@ * rule flags those utilities at authoring time and in CI so the tokenised * palette cannot regress. * - * Match: a className fragment - * (text|bg|border|ring|stroke|fill|from|to|via)-- - * where is one of the Tailwind stock hues and is a - * numeric 50…950 shade. The `-` suffix is what pins it to the raw - * palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic - * tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and - * so never match. + * v1.27.x — the rule grows three checks that closed known bypasses: * - * Scope: `src/components/**` + `src/app/**`. The check inspects string - * literals and template-literal chunks (covering `className="…"`, + * - `palette` (error) — the original check: a className fragment + * `(text|bg|border|ring|stroke|fill|from|to|via)--` + * with a numeric 50…950 shade. Bare `bg-white` / `text-black` (QR + * quiet-zone) and semantic tokens never carry a numeric shade and so + * never match. + * - `color-props` (error) — colour-shaped JS props / object keys + * (`color` / `fill` / `stroke` / `background*`) receiving a string + * that contains a `#hex` / `rgb(` / `hsl(` / `oklch(` literal, e.g. + * `color="#22c55e"` or a Recharts `stroke`. Chart colours are passed + * as `var(--token)` strings instead. The `themeColor` viewport + * metadata is exempt (the PWA chrome colour is read by the browser + * before any stylesheet loads, so it must stay a literal). + * - `arbitrary-value` (error) — Tailwind arbitrary colour values + * (`bg-[#…]`, `text-[oklch(…)]`). `…-[var(--token)]` never matches. + * - `dracula` (WARN, staged) — raw `*-dracula-*` utilities. ~250 + * legacy sites predate the token system; the light-theme overrides in + * `globals.css` defuse their AA failures, so the check ships as a + * warning first (registered separately as + * `healthlog/no-dracula-utility`). It moves to error once the + * semantic sweep (status meaning → `text-success/warning/info/ + * destructive`, brand use → `--brand-*` tokens) has retired the + * legacy sites. + * + * The `checks` option selects which detectors run, so the flat config can + * register the same module twice at different severities (see + * `eslint-plugins/healthlog/index.js` + `eslint.config.mjs`). + * + * Scope: `src/components/**` + `src/app/**`. The string checks inspect + * string literals and template-literal chunks (covering `className="…"`, * `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic - * match, mirroring the queryKey-factory / safe-fetch-required rules. + * match, mirroring the queryKey-factory / safe-fetch-required rules. The + * `color-props` check additionally inspects JSX attributes and object + * properties so it can key off the prop NAME. * * Exempt files (narrow, path-suffix matched): * - `src/app/global-error.tsx` — the App-Router global error boundary @@ -53,6 +76,27 @@ const ENFORCED_ROOTS = ["src/components/", "src/app/"]; const RAW_PALETTE_RE = /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/; +// Raw `dracula-*` utility: the pre-token palette accessed directly +// (`text-dracula-green`, `bg-dracula-orange/15`). Same prefix set and +// modifier tolerance as the palette check. +const DRACULA_RE = + /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-dracula-[a-z]+\b/; + +// Tailwind arbitrary colour value: `bg-[#…]`, `text-[rgb(…)]`, +// `border-[oklch(…)]`. Matching on the bracket-open plus a colour-literal +// head keeps `…-[var(--token)]` (the sanctioned escape hatch) clean. +const ARBITRARY_COLOR_RE = /-\[(?:#|rgba?\(|hsla?\(|oklch\()/; + +// Colour-shaped prop / object-key names for the `color-props` check. +// Anchored so `themeColor` / `colorScheme` etc. never match. +const COLOR_PROP_NAME_RE = /^(?:color|fill|stroke|background[A-Za-z]*)$/; + +// A colour literal inside a string value: hex, rgb()/rgba(), hsl()/hsla(), +// oklch(). `var(--token)` strings never match. +const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/; + +const DEFAULT_CHECKS = ["palette", "color-props", "arbitrary-value"]; + function toPosix(filename) { return filename.replace(/\\/g, "/"); } @@ -74,18 +118,71 @@ function isEnforced(filename) { return true; } +/** + * The `themeColor` viewport metadata carries per-scheme colour literals + * (`{ media, color }` entries) that the browser reads before any + * stylesheet exists — the one sanctioned inline-colour site. Walk the + * ancestor chain for a `themeColor` property so those entries stay exempt + * without exempting the whole layout file. + */ +function isInsideThemeColor(node) { + for (let cur = node.parent; cur; cur = cur.parent) { + if ( + cur.type === "Property" && + !cur.computed && + ((cur.key.type === "Identifier" && cur.key.name === "themeColor") || + (cur.key.type === "Literal" && cur.key.value === "themeColor")) + ) { + return true; + } + } + return false; +} + +/** Extract the static string content of a Literal / TemplateLiteral value. */ +function staticString(node) { + if (!node) return null; + if (node.type === "Literal" && typeof node.value === "string") { + return node.value; + } + if (node.type === "TemplateLiteral") { + return node.quasis.map((q) => q.value.cooked ?? q.value.raw).join(" "); + } + return null; +} + /** @type {import("eslint").Rule.RuleModule} */ const noRawPaletteColorRule = { meta: { type: "problem", docs: { description: - "Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).", + "Ban raw colour literals in app UI — Tailwind stock palette utilities, raw dracula-* utilities, hex/rgb/hsl/oklch in colour-shaped JS props, and arbitrary-value colours; route every tone through a semantic token.", }, - schema: [], + schema: [ + { + type: "object", + properties: { + checks: { + type: "array", + items: { + enum: ["palette", "dracula", "color-props", "arbitrary-value"], + }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], messages: { rawPalette: 'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].', + draculaUtility: + 'Raw dracula utility "{{match}}" reads the pre-token palette directly. Status meaning → text-success / text-warning / text-info / text-destructive (and their bg/border forms); brand identity → a named --brand-* token. Legacy sites survive on the light-theme overrides in globals.css; do not add new ones.', + colorProp: + 'Colour-shaped prop "{{name}}" receives the raw colour literal "{{value}}". Pass a theme token instead — color="var(--success)" / stroke="var(--chart-1)" — so both themes and future token retunes apply.', + arbitraryColor: + 'Arbitrary-value colour utility "{{match}}" hard-codes a tone outside the token system. Use a semantic utility (text-warning, bg-success/10) or a token reference (…-[var(--token)]).', }, }, create(context) { @@ -94,16 +191,56 @@ const noRawPaletteColorRule = { return {}; } + const options = context.options?.[0] ?? {}; + const checks = new Set(options.checks ?? DEFAULT_CHECKS); + function checkText(node, text) { if (typeof text !== "string") return; - const m = RAW_PALETTE_RE.exec(text); - if (m) { - context.report({ - node, - messageId: "rawPalette", - data: { match: m[0] }, - }); + if (checks.has("palette")) { + const m = RAW_PALETTE_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "rawPalette", + data: { match: m[0] }, + }); + } } + if (checks.has("dracula")) { + const m = DRACULA_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "draculaUtility", + data: { match: m[0] }, + }); + } + } + if (checks.has("arbitrary-value")) { + const m = ARBITRARY_COLOR_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "arbitraryColor", + data: { match: m[0] }, + }); + } + } + } + + function checkColorProp(node, name, valueNode) { + if (!checks.has("color-props")) return; + if (!COLOR_PROP_NAME_RE.test(name)) return; + const value = staticString(valueNode); + if (value === null) return; + const m = COLOR_LITERAL_RE.exec(value); + if (!m) return; + if (isInsideThemeColor(node)) return; + context.report({ + node, + messageId: "colorProp", + data: { name, value: m[0] }, + }); } return { @@ -115,6 +252,25 @@ const noRawPaletteColorRule = { TemplateElement(node) { checkText(node, node.value.cooked ?? node.value.raw); }, + JSXAttribute(node) { + if (node.name?.type !== "JSXIdentifier") return; + const valueNode = + node.value?.type === "JSXExpressionContainer" + ? node.value.expression + : node.value; + checkColorProp(node, node.name.name, valueNode); + }, + Property(node) { + if (node.computed) return; + const name = + node.key.type === "Identifier" + ? node.key.name + : node.key.type === "Literal" && typeof node.key.value === "string" + ? node.key.value + : null; + if (!name) return; + checkColorProp(node, name, node.value); + }, }; }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3662faa09..4776807ab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,9 +48,22 @@ const eslintConfig = defineConfig([ // vocabulary onto semantic tokens. Raw Tailwind palette utilities // (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under // src/components + src/app are banned so the tokenised palette can't - // regress. `src/app/global-error.tsx` (renders without the token - // stylesheet) and test files are exempt; see the rule file. - "healthlog/no-raw-palette-color": "error", + // regress. v1.27.x extends the rule with two further error-level + // checks: colour-shaped JS props carrying a raw `#hex`/`rgb(`/`hsl(`/ + // `oklch(` literal (the Recharts `color`/`stroke`/`fill` blind spot), + // and Tailwind arbitrary-value colours (`bg-[#…]`). + // `src/app/global-error.tsx` (renders without the token stylesheet), + // the `themeColor` viewport metadata, and test files are exempt; see + // the rule file. + "healthlog/no-raw-palette-color": [ + "error", + { checks: ["palette", "color-props", "arbitrary-value"] }, + ], + // Staged: raw `*-dracula-*` utilities warn for now — ~250 legacy + // sites predate the token system and the light-theme overrides in + // globals.css keep them readable. Moves to error once the semantic + // sweep has retired the legacy sites (see the rule header). + "healthlog/no-dracula-utility": ["warn", { checks: ["dracula"] }], }, }, ]); diff --git a/messages/de.json b/messages/de.json index 76d77dcbb..23c44b9ca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -187,7 +187,18 @@ "illnessColType": "Art", "illnessColLifecycle": "Verlauf", "illnessColOnset": "Beginn", - "illnessColResolved": "Abgeklungen" + "illnessColResolved": "Abgeklungen", + "allergiesTitle": "Allergien & Unverträglichkeiten", + "allergiesColSubstance": "Substanz", + "allergiesColCategory": "Kategorie", + "allergiesColKind": "Art", + "allergiesColSeverity": "Schweregrad", + "allergiesColReaction": "Reaktion", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Familienanamnese", + "familyHistoryColRelationship": "Verwandtschaft", + "familyHistoryColCondition": "Erkrankung", + "familyHistoryColAgeAtOnset": "Alter bei Beginn" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", "scoreComputing": "Score wird berechnet…", + "ringDoses": "Dosen heute", + "ringDosesAria": "{taken} von {scheduled} Dosen heute genommen", "verdict": { "doseOverdue": "Eine Dosis {name} ist überfällig.", "doseOverdueNoName": "Eine Dosis ist überfällig.", @@ -327,8 +340,7 @@ "logMeasurement": "Messung erfassen" }, "briefing": { - "title": "Tagesbriefing", - "viewAll": "Insights öffnen" + "title": "Tagesbriefing" } }, "greetingSalutation": "Hallo, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).", "generate": "Erstellen", "error": "Export fehlgeschlagen ({code}).", - "labs": "Laborwerte" + "labs": "Laborwerte", + "allergies": "Allergien & Unverträglichkeiten", + "familyHistory": "Familienanamnese" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -7650,6 +7664,14 @@ "2": "An mehr als der Hälfte der Tage", "3": "Beinahe jeden Tag" }, + "functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?", + "functional": { + "0": "Überhaupt nicht erschwert", + "1": "Etwas erschwert", + "2": "Sehr erschwert", + "3": "Extrem erschwert" + }, + "functionalOptionalHint": "Optional — du kannst auch ohne Antwort auf diese Frage absenden.", "result": { "title": "Dein Ergebnis", "totalLabel": "Gesamtwert", diff --git a/messages/en.json b/messages/en.json index 6e7531238..9c733b7c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -187,7 +187,18 @@ "illnessColType": "Type", "illnessColLifecycle": "Course", "illnessColOnset": "Onset", - "illnessColResolved": "Resolved" + "illnessColResolved": "Resolved", + "allergiesTitle": "Allergies & intolerances", + "allergiesColSubstance": "Substance", + "allergiesColCategory": "Category", + "allergiesColKind": "Kind", + "allergiesColSeverity": "Severity", + "allergiesColReaction": "Reaction", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Family history", + "familyHistoryColRelationship": "Relative", + "familyHistoryColCondition": "Condition", + "familyHistoryColAgeAtOnset": "Age at onset" }, "errorBoundary": { "title": "Something went wrong", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", "scoreComputing": "Calculating score…", + "ringDoses": "Doses today", + "ringDosesAria": "{taken} of {scheduled} doses taken today", "verdict": { "doseOverdue": "A dose of {name} is overdue.", "doseOverdueNoName": "A dose is overdue.", @@ -327,8 +340,7 @@ "logMeasurement": "Log measurement" }, "briefing": { - "title": "Today’s briefing", - "viewAll": "Open Insights" + "title": "Today’s briefing" } }, "greetingSalutation": "Hi, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).", "generate": "Generate", "error": "Export failed ({code}).", - "labs": "Lab results" + "labs": "Lab results", + "allergies": "Allergies & intolerances", + "familyHistory": "Family history" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -7650,6 +7664,14 @@ "2": "More than half the days", "3": "Nearly every day" }, + "functionalTitle": "If you checked off any problems, how difficult have these made it to do your work, take care of things at home, or get along with other people?", + "functional": { + "0": "Not difficult at all", + "1": "Somewhat difficult", + "2": "Very difficult", + "3": "Extremely difficult" + }, + "functionalOptionalHint": "Optional — you can submit without answering this question.", "result": { "title": "Your result", "totalLabel": "Total score", diff --git a/messages/es.json b/messages/es.json index 63288bf42..08069dda2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -187,7 +187,18 @@ "illnessColType": "Tipo", "illnessColLifecycle": "Curso", "illnessColOnset": "Inicio", - "illnessColResolved": "Resuelta" + "illnessColResolved": "Resuelta", + "allergiesTitle": "Alergias e intolerancias", + "allergiesColSubstance": "Sustancia", + "allergiesColCategory": "Categoría", + "allergiesColKind": "Tipo", + "allergiesColSeverity": "Gravedad", + "allergiesColReaction": "Reacción", + "allergiesColStatus": "Estado", + "familyHistoryTitle": "Antecedentes familiares", + "familyHistoryColRelationship": "Parentesco", + "familyHistoryColCondition": "Afección", + "familyHistoryColAgeAtOnset": "Edad de inicio" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Aún no hay datos suficientes", "scoreComputing": "Calculando la puntuación …", + "ringDoses": "Dosis de hoy", + "ringDosesAria": "{taken} de {scheduled} dosis tomadas hoy", "verdict": { "doseOverdue": "Una dosis de {name} está atrasada.", "doseOverdueNoName": "Una dosis está atrasada.", @@ -327,8 +340,7 @@ "logMeasurement": "Registrar medición" }, "briefing": { - "title": "Resumen de hoy", - "viewAll": "Abrir Insights" + "title": "Resumen de hoy" } }, "greetingSalutation": "Hola, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).", "generate": "Generar", "error": "Error en la exportación ({code}).", - "labs": "Resultados de análisis" + "labs": "Resultados de análisis", + "allergies": "Alergias e intolerancias", + "familyHistory": "Antecedentes familiares" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -7650,6 +7664,14 @@ "2": "Más de la mitad de los días", "3": "Casi todos los días" }, + "functionalTitle": "Si marcaste algún problema, ¿cuánta dificultad te han causado para trabajar, ocuparte de la casa o relacionarte con otras personas?", + "functional": { + "0": "Ninguna dificultad", + "1": "Algo de dificultad", + "2": "Mucha dificultad", + "3": "Dificultad extrema" + }, + "functionalOptionalHint": "Opcional: puedes enviar sin responder esta pregunta.", "result": { "title": "Tu resultado", "totalLabel": "Puntuación total", diff --git a/messages/fr.json b/messages/fr.json index b05f33f29..29e16188a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -187,7 +187,18 @@ "illnessColType": "Type", "illnessColLifecycle": "Évolution", "illnessColOnset": "Début", - "illnessColResolved": "Résolue" + "illnessColResolved": "Résolue", + "allergiesTitle": "Allergies et intolérances", + "allergiesColSubstance": "Substance", + "allergiesColCategory": "Catégorie", + "allergiesColKind": "Type", + "allergiesColSeverity": "Sévérité", + "allergiesColReaction": "Réaction", + "allergiesColStatus": "Statut", + "familyHistoryTitle": "Antécédents familiaux", + "familyHistoryColRelationship": "Lien de parenté", + "familyHistoryColCondition": "Affection", + "familyHistoryColAgeAtOnset": "Âge au début" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Pas encore assez de données", "scoreComputing": "Calcul du score en cours …", + "ringDoses": "Doses du jour", + "ringDosesAria": "{taken} des {scheduled} doses prises aujourd'hui", "verdict": { "doseOverdue": "Une dose de {name} est en retard.", "doseOverdueNoName": "Une dose est en retard.", @@ -327,8 +340,7 @@ "logMeasurement": "Saisir une mesure" }, "briefing": { - "title": "Briefing du jour", - "viewAll": "Ouvrir Insights" + "title": "Briefing du jour" } }, "greetingSalutation": "Bonjour, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).", "generate": "Générer", "error": "Échec de l’export ({code}).", - "labs": "Résultats d'analyses" + "labs": "Résultats d'analyses", + "allergies": "Allergies et intolérances", + "familyHistory": "Antécédents familiaux" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -7650,6 +7664,14 @@ "2": "Plus de la moitié des jours", "3": "Presque tous les jours" }, + "functionalTitle": "Si tu as coché des problèmes, à quel point t'ont-ils gêné(e) dans ton travail, à la maison ou dans tes relations avec les autres ?", + "functional": { + "0": "Pas du tout gênant", + "1": "Un peu gênant", + "2": "Très gênant", + "3": "Extrêmement gênant" + }, + "functionalOptionalHint": "Facultatif — tu peux envoyer sans répondre à cette question.", "result": { "title": "Ton résultat", "totalLabel": "Score total", diff --git a/messages/it.json b/messages/it.json index 992787bc2..baafa256b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -187,7 +187,18 @@ "illnessColType": "Tipo", "illnessColLifecycle": "Decorso", "illnessColOnset": "Inizio", - "illnessColResolved": "Risolta" + "illnessColResolved": "Risolta", + "allergiesTitle": "Allergie e intolleranze", + "allergiesColSubstance": "Sostanza", + "allergiesColCategory": "Categoria", + "allergiesColKind": "Tipo", + "allergiesColSeverity": "Gravità", + "allergiesColReaction": "Reazione", + "allergiesColStatus": "Stato", + "familyHistoryTitle": "Anamnesi familiare", + "familyHistoryColRelationship": "Parentela", + "familyHistoryColCondition": "Condizione", + "familyHistoryColAgeAtOnset": "Età di esordio" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Dati ancora insufficienti", "scoreComputing": "Calcolo del punteggio in corso …", + "ringDoses": "Dosi di oggi", + "ringDosesAria": "{taken} di {scheduled} dosi assunte oggi", "verdict": { "doseOverdue": "Una dose di {name} è in ritardo.", "doseOverdueNoName": "Una dose è in ritardo.", @@ -327,8 +340,7 @@ "logMeasurement": "Registra misurazione" }, "briefing": { - "title": "Briefing di oggi", - "viewAll": "Apri Insights" + "title": "Briefing di oggi" } }, "greetingSalutation": "Ciao, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).", "generate": "Genera", "error": "Esportazione non riuscita ({code}).", - "labs": "Risultati degli esami" + "labs": "Risultati degli esami", + "allergies": "Allergie e intolleranze", + "familyHistory": "Anamnesi familiare" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -7650,6 +7664,14 @@ "2": "Più della metà dei giorni", "3": "Quasi ogni giorno" }, + "functionalTitle": "Se hai indicato dei problemi, quanto ti hanno reso difficile lavorare, occuparti della casa o andare d'accordo con gli altri?", + "functional": { + "0": "Per niente difficile", + "1": "Un po' difficile", + "2": "Molto difficile", + "3": "Estremamente difficile" + }, + "functionalOptionalHint": "Facoltativo — puoi inviare anche senza rispondere a questa domanda.", "result": { "title": "Il tuo risultato", "totalLabel": "Punteggio totale", diff --git a/messages/pl.json b/messages/pl.json index 0ea37c756..d129e4f98 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -187,7 +187,18 @@ "illnessColType": "Typ", "illnessColLifecycle": "Przebieg", "illnessColOnset": "Początek", - "illnessColResolved": "Ustąpiła" + "illnessColResolved": "Ustąpiła", + "allergiesTitle": "Alergie i nietolerancje", + "allergiesColSubstance": "Substancja", + "allergiesColCategory": "Kategoria", + "allergiesColKind": "Rodzaj", + "allergiesColSeverity": "Nasilenie", + "allergiesColReaction": "Reakcja", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Wywiad rodzinny", + "familyHistoryColRelationship": "Pokrewieństwo", + "familyHistoryColCondition": "Schorzenie", + "familyHistoryColAgeAtOnset": "Wiek zachorowania" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -307,6 +318,8 @@ "scoreLabel": "Health Score", "scoreProvisional": "Za mało danych", "scoreComputing": "Trwa obliczanie wyniku …", + "ringDoses": "Dzisiejsze dawki", + "ringDosesAria": "Przyjęto {taken} z {scheduled} dawek dzisiaj", "verdict": { "doseOverdue": "Dawka {name} jest zaległa.", "doseOverdueNoName": "Dawka jest zaległa.", @@ -327,8 +340,7 @@ "logMeasurement": "Zapisz pomiar" }, "briefing": { - "title": "Briefing dnia", - "viewAll": "Otwórz Insights" + "title": "Briefing dnia" } }, "greetingSalutation": "Cześć, {name}", @@ -5061,7 +5073,9 @@ "fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).", "generate": "Generuj", "error": "Eksport nie powiódł się ({code}).", - "labs": "Wyniki badań" + "labs": "Wyniki badań", + "allergies": "Alergie i nietolerancje", + "familyHistory": "Wywiad rodzinny" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -7650,6 +7664,14 @@ "2": "Więcej niż połowę dni", "3": "Niemal codziennie" }, + "functionalTitle": "Jeśli zaznaczyłeś(-aś) jakiekolwiek problemy, na ile utrudniły Ci one pracę, prowadzenie domu lub kontakty z innymi ludźmi?", + "functional": { + "0": "Wcale nie utrudniły", + "1": "Trochę utrudniły", + "2": "Bardzo utrudniły", + "3": "Skrajnie utrudniły" + }, + "functionalOptionalHint": "Opcjonalne — możesz wysłać bez odpowiedzi na to pytanie.", "result": { "title": "Twój wynik", "totalLabel": "Wynik łączny", diff --git a/package.json b/package.json index efb12c048..bc553cff8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.7", + "version": "1.27.8", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index f930747ee..cf6fe26e4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.27.7"; + /* @sw-version-fallback */ "v1.27.8"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 7fb0012ad..c8ebc622e 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -92,6 +92,8 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { sleep: false, cycle: false, labs: true, + allergies: true, + familyHistory: false, }; const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); @@ -116,6 +118,8 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { sleep: false, cycle: false, labs: false, + allergies: false, + familyHistory: false, }; vi.mocked(prisma.user.findUnique).mockResolvedValue({ doctorReportPrefsJson: current, diff --git a/src/app/globals.css b/src/app/globals.css index acc5100af..7d80d2e82 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -335,7 +335,26 @@ --chart-3: #a3134d; --chart-4: #036a96; --chart-5: #a34d14; + /* + * Light-mode (Alucard) overrides for the raw `--dracula-*` primitives. + * ~250 legacy `text-dracula-*` / opacity-suffixed `bg-dracula-*` call + * sites still read these directly; without an override the dark neons render on the white + * card at 1.2–1.6:1. Each value below is tuned to the AA light palette + * already in this block (same hue family, measured >=4.5:1 on both + * `--card` oklch(0.988 0.003 300) and `--background` oklch(0.964 0.004 + * 300)), so every legacy utility inherits a readable tone until the + * semantic sweep retires it. + */ + --dracula-cyan: #036a96; /* shadows --info — 5.78:1 card / 5.39:1 bg */ + --dracula-green: #14720a; /* shadows --success — 5.89:1 card / 5.49:1 bg */ + --dracula-orange: #a34d14; /* shadows --warning — 5.59:1 card / 5.21:1 bg */ + --dracula-pink: #a3134d; /* shadows --chart-3 — 7.35:1 card / 6.85:1 bg */ + --dracula-purple: #644ac9; /* shadows --primary/chart-1 — 6.03:1 card / 5.62:1 bg */ + --dracula-red: #cb3a2a; /* shadows --destructive — 4.85:1 card / 4.52:1 bg */ --dracula-yellow: #846e15; + /* Lavender has no semantic twin; a violet one step lighter than purple, + * AA-tuned — 4.97:1 card / 4.63:1 bg. */ + --dracula-lavender: #7c50e0; --sidebar: oklch(0.988 0.003 300); --sidebar-foreground: oklch(0.3 0.012 295); --sidebar-primary: #644ac9; diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx index 895fbdedb..ae84730cb 100644 --- a/src/app/insights/grip-strength/page.tsx +++ b/src/app/insights/grip-strength/page.tsx @@ -22,7 +22,7 @@ export default function InsightsGripStrengthPage() { insightMetric="GRIP_STRENGTH" chartKey="gripStrength" i18nPrefix="insights.gripStrength" - color="#22c55e" + color="var(--success)" unit="kg" yAxisUnit="kg" statIcon={Dumbbell} diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index e95c6a306..e5b7d4af7 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -211,7 +211,7 @@ export default function InsightsMedikamentePage() { : t("medications.categoryOther"); return ( - +
{med.dose} diff --git a/src/app/insights/pain/page.tsx b/src/app/insights/pain/page.tsx index 895f25b68..b723c7872 100644 --- a/src/app/insights/pain/page.tsx +++ b/src/app/insights/pain/page.tsx @@ -21,7 +21,7 @@ export default function InsightsPainPage() { insightMetric="PAIN_NRS" chartKey="painNrs" i18nPrefix="insights.pain" - color="#f97316" + color="var(--warning)" unit="" yAxisUnit="/10" statIcon={Activity} diff --git a/src/app/insights/waist-to-height/page.tsx b/src/app/insights/waist-to-height/page.tsx index abae1bbf8..6f9b27c1c 100644 --- a/src/app/insights/waist-to-height/page.tsx +++ b/src/app/insights/waist-to-height/page.tsx @@ -21,7 +21,7 @@ export default function InsightsWaistToHeightPage() { insightMetric="WAIST_TO_HEIGHT" chartKey="waistToHeight" i18nPrefix="insights.waistToHeight" - color="#8b5cf6" + color="var(--chart-1)" unit="" statIcon={Scaling} emptyStateIcon={} diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx index e838df0ee..0d4106339 100644 --- a/src/app/insights/waist/page.tsx +++ b/src/app/insights/waist/page.tsx @@ -21,7 +21,7 @@ export default function InsightsWaistPage() { insightMetric="WAIST_CIRCUMFERENCE" chartKey="waistCircumference" i18nPrefix="insights.waist" - color="#0ea5e9" + color="var(--info)" unit="cm" yAxisUnit="cm" statIcon={Ruler} diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index c9dfa3a1a..cfaac77ae 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -996,7 +996,7 @@ export function MoodChart({ return ( - + {/* v1.4.19 A2 — mobile-first header: stack title row above controls row on small viewports so the bucket / comparison chips never push the range tabs into a 2nd line. ≥sm goes diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index e0f51a54a..829d89886 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -268,13 +268,13 @@ export function CustomMetricDetail({ {isLoading ? ( - + ) : entries.length === 0 ? ( - + } title={t("customMetrics.detail.emptyTitle")} @@ -296,7 +296,7 @@ export function CustomMetricDetail({ icon={Gauge} /> - + - - + {!hasCurve ? ( diff --git a/src/components/cycle/cycle-symptom-patterns.tsx b/src/components/cycle/cycle-symptom-patterns.tsx index 84a4e2a2a..a9d0ee94f 100644 --- a/src/components/cycle/cycle-symptom-patterns.tsx +++ b/src/components/cycle/cycle-symptom-patterns.tsx @@ -3,7 +3,8 @@ import { useMemo } from "react"; import { Activity } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import { PHASE_HUE } from "./phase-tokens"; @@ -56,10 +57,10 @@ export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) { return ( - - + {rows.length === 0 ? ( diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index cb24978a5..f7175c2c8 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -10,6 +10,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; import type { CyclePrediction, CycleHistoryResponse } from "./types"; @@ -64,10 +65,7 @@ export function PredictionsPanel({
- - + {rawChartMode ? ( diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx index 8c72401be..2d6a6eb34 100644 --- a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -117,7 +117,10 @@ describe("", () => { }); expect(html).toContain('data-slot="dashboard-briefing-spotlight"'); expect(html).toContain("Short on sleep this week"); - expect(html).toContain('href="/insights"'); + // The heading itself links to the briefing card anchor; the former + // separate "view all" text link is gone. + expect(html).toContain('href="/insights#daily-briefing"'); + expect(html).not.toContain('data-slot="dashboard-briefing-spotlight-link"'); expect(html).toContain("-1.2 h"); // Signals win over key findings when present. expect(html).not.toContain("should not appear when signals exist"); diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index b7e02fe1a..e093e3a8f 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -375,7 +375,12 @@ describe(" — ring row (v1.27.7)", () => { healthScore: { score: 82, band: "green", delta: 2 }, scoreRings: [ { id: "READINESS", score: 71, band: "green" }, - { id: "MED_COMPLIANCE", score: 95, band: "green" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, ], }), ); @@ -389,9 +394,14 @@ describe(" — ring row (v1.27.7)", () => { const rings = html.match(/data-slot="score-ring"/g) ?? []; expect(rings).toHaveLength(3); // Labels (de locale): the derived ring reuses its wellness-strip - // title, the adherence ring the medications adherence label. + // title; the dose ring names today's tally and shows it as + // taken/scheduled over the constant green arc — never the band + // gradient (a pending morning dose is not an alert state). expect(html).toContain("Bereitschaft"); - expect(html).toContain("Therapietreue"); + expect(html).toContain("Dosen heute"); + expect(html).toContain(">1/3<"); + expect(html).toContain("var(--ring-green-from)"); + expect(html).not.toContain("var(--ring-yellow-from)"); }); it("no dose text row renders anymore — the ring row carries the slot", () => { diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx index dcdc6f95a..5954030a8 100644 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -152,18 +152,17 @@ export function BriefingSpotlight({ return (
-
-

- {t("dashboard.hero.briefing.title")} -

- - {t("dashboard.hero.briefing.viewAll")} - -
+ {/* The heading itself routes to the briefing card on /insights — + quiet (no underline, hover lift only). The former separate + "open Insights" text link is gone: it thickened the card while + every signal row below already deep-links. */} + + {t("dashboard.hero.briefing.title")} +
{rows.map((row, index) => { const Icon = METRIC_ICON[row.sourceMetric]; diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index 17093d7f5..08133cb84 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -53,6 +53,7 @@ import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { RingHue } from "@/components/insights/derived/ring-hues"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight"; +import { METRIC_HREF } from "@/components/insights/daily-briefing"; import { resolveDashboardVerdict, type DashboardVerdictVariant, @@ -99,10 +100,12 @@ const CTA_LABEL_KEY: Partial> = { /** * Per-ring hue for the selected score rings — the wellness-strip - * vocabulary. MED_COMPLIANCE deliberately carries NO hue: the adherence - * ring falls back to the ring's band gradient (green / yellow / red - * semantic tokens), because for adherence the band IS the message — - * exactly the classification colouring the targets tile uses. + * vocabulary, so every hero ring paints EXACTLY the hue its insights + * sibling paints. MED_COMPLIANCE carries no per-metric hue entry: the + * dose ring pins the ring system's green arc (`band="green"`, constant) + * — the same green family the medication compliance surfaces in + * Insights paint for a taken dose — instead of a band gradient that + * would flash yellow/red over pending morning doses. */ const RING_HUE_BY_ID: Partial> = { READINESS: "readiness", @@ -110,12 +113,13 @@ const RING_HUE_BY_ID: Partial> = { SLEEP_SCORE: "sleep", }; -/** Per-ring label key — reuses the existing score / adherence labels. */ +/** Per-ring label key — reuses the existing score labels; the dose ring + * names today's tally. */ const RING_LABEL_KEY: Record = { READINESS: "insights.derived.composite.READINESS.title", RECOVERY_SCORE: "insights.derived.scores.recovery", SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "medications.compliance", + MED_COMPLIANCE: "dashboard.hero.ringDoses", }; export function DashboardHero({ @@ -196,6 +200,23 @@ export function DashboardHero({ const cta = verdict.cta; const ctaLabelKey = CTA_LABEL_KEY[verdict.variant]; + // Link-CTA verdicts WITHOUT a button label (scoreDrop, briefing) make + // the sentence itself the link — after the broad "open Insights" + // button was dropped the sentence was a dead end. The briefing + // sentence deep-links to the picked finding's metric sub-page (the + // METRIC_HREF map the briefing rows use); metrics without a routed + // sub-page fall back to the resolver's `/insights` href. Verdicts + // with their own action (take dose, view BP, …) keep the button. + const sentenceHref = + cta !== null && cta.kind === "link" && !ctaLabelKey + ? verdict.variant === "briefing" && + typeof verdict.values.sourceMetric === "string" + ? (METRIC_HREF[ + verdict.values.sourceMetric as keyof typeof METRIC_HREF + ] ?? cta.href) + : cta.href + : null; + // ── Provisional score copy ────────────────────────────────────────── // The score's four pillars are weight, BP, mood, and medication // compliance. When ANY of them already carries data, a null score is @@ -247,13 +268,24 @@ export function DashboardHero({ {welcomeText}

-

- {sentence} -

+ {sentenceHref ? ( + + {sentence} + + ) : ( +

+ {sentence} +

+ )} {cta !== null && ctaLabelKey ? ( cta.kind === "link" ? (
))} - {t("illness.loadError")} -

+ void refetch()} + /> ); } @@ -109,7 +116,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) { {episode?.note ? ( - +

{t("illness.detail.note")}

diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 84a37e9a5..9b7efe866 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -132,7 +132,7 @@ function EpisodeCard({ return ( - + - -
-
- {title} -

{subtitle}

-
- {result.status === "ok" && ( - - {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} - - )} + +
+ + {t(CONFIDENCE_LABEL_KEY[result.confidenceBand.label])} + + ) : undefined + } + /> +

{subtitle}

diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index d592ec7d5..de811b058 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -379,9 +379,13 @@ export function DailyBriefing({ // straight on its content and the surface reclaims the vertical // space the in-card header used to occupy.
- {hasScore ? displayedRounded : "—"} + {hasScore ? (valueText ?? displayedRounded) : "—"} {label ? ( diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index 4704087dd..6a0214972 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -4,7 +4,8 @@ import Link from "next/link"; import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; @@ -50,7 +51,7 @@ export function GlucoseClinicalPanel() { if (query.isLoading) { return ( - + @@ -96,11 +97,11 @@ export function GlucoseClinicalPanel() { return ( - - - + + - +
- - + {t("insights.bloodGlucose.clinical.subtitle", { days: Math.max(1, Math.round(clinical.actualSpanDays)), diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 8fa944a1d..d24356b73 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -82,8 +82,13 @@ export interface HealthKitMetricPageProps { * sub-key off the prefix. */ i18nPrefix: string; - /** Single canonical colour for the chart line. */ - color: string; + /** + * Single canonical colour for the chart line. Typed as a `var(--…)` + * template so pages can only pass a theme token (`"var(--success)"`), + * never a raw hex — raw stock hex ignores both themes and every future + * token retune (see UI standards, colour token rules). + */ + color: `var(--${string})`; /** Unit suffix the chart renders next to the value. */ unit: string; /** Optional y-axis label shown above the unit. */ diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 43cc119af..08c86e806 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -109,7 +109,7 @@ export function InsightStatusCard({ data-testid="insight-status-card-preparing" className="gap-1.5 py-4 md:py-5" > - + @@ -146,7 +146,7 @@ export function InsightStatusCard({ // baseline across every assessment-card state. className="gap-1.5 py-4 md:py-5" > - +
@@ -166,7 +166,7 @@ export function InsightStatusCard({ if (!hasProvider) { return ( - + @@ -188,7 +188,7 @@ export function InsightStatusCard({ if (!text) { return ( - + @@ -231,7 +231,7 @@ export function InsightStatusCard({ // sibling tiles. The loading skeleton keeps its own spacing. className="animate-insight-in gap-1.5 py-4 md:py-5" > - + {/* v1.11.5 — the top-right "cached" label was removed: it surfaced an implementation detail and devalued the assessment. The card still consumes the warm cache; it just no longer announces it. diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index fe270ac1c..2f5622e2b 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -312,7 +312,7 @@ function TargetReferencePanel({ per-context label (Fasting / Postprandial / …) carries real, non-duplicative info, so it stays as a sub-caption under the header. */} - + - -
- - {t(TITLE_KEY[kind])} - - {hasResult && data.result && ( -
- - {t(STRENGTH_KEY[data.result.strength])} - - {/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the - scatter as a half-empty row. Move it into an explainer icon - in the header so the card footprint stays tight. */} - -
- )} -
+ + + + {t(STRENGTH_KEY[data.result.strength])} + + {/* v1.12.4 (C4) — the "{n} paired days · r" line sat under the + scatter as a half-empty row. Move it into an explainer icon + in the header so the card footprint stays tight. */} + +
+ ) : undefined + } + />
{hasResult && data.result ? ( diff --git a/src/components/insights/mood/mood-in-target-tile.tsx b/src/components/insights/mood/mood-in-target-tile.tsx index 9f19c6bbc..c60c5bcd2 100644 --- a/src/components/insights/mood/mood-in-target-tile.tsx +++ b/src/components/insights/mood/mood-in-target-tile.tsx @@ -27,7 +27,7 @@ export function MoodInTargetTile({ pct }: { pct: number | null }) { return ( - + diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index b14dc7626..025993f3f 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -167,7 +167,7 @@ function SectionCard({ }) { return ( - + {children} @@ -289,7 +289,7 @@ export function MoodInsightsSections({ // above its body instead of floating ~16-24 px above it. className="animate-insight-in gap-1.5 py-4 md:py-5" > - + - + - + - +
- - {t("insights.sleep.hypnogram.title")} - + {t("insights.sleep.hypnogram.subtitle", { asleep: formatMinutes(session.asleepMinutes, locale), diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index f79bce378..b413419e0 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -209,7 +209,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) { return ( - +
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 8638beae8..282214646 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -80,8 +80,10 @@ describe("", () => { for (const html of [partial, ready]) { expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4"); expect(html).toContain("md:py-6"); - // Semantic info token, not the raw Dracula cyan. - expect(html).toContain("text-info"); + // Header rides the canonical TileHeader (foreground icon), never a + // hand-rolled accent-coloured row or the raw Dracula cyan. + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("text-info"); expect(html).not.toContain("text-dracula-cyan"); } }); @@ -123,11 +125,12 @@ describe("", () => { expect(html).toContain("text-2xl"); }); - it("uses the semantic info token, never the raw Dracula cyan", () => { + it("renders the canonical TileHeader, never the raw Dracula cyan", () => { const learning = render(); const ready = render(); for (const html of [learning, ready]) { - expect(html).toContain("text-info"); + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("text-info"); expect(html).not.toContain("text-dracula-cyan"); } }); diff --git a/src/components/insights/sleep/average-sleep-card.tsx b/src/components/insights/sleep/average-sleep-card.tsx index 81cb2b9c1..2071624e0 100644 --- a/src/components/insights/sleep/average-sleep-card.tsx +++ b/src/components/insights/sleep/average-sleep-card.tsx @@ -3,8 +3,9 @@ import { BedDouble } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { TileHeader } from "@/components/insights/tile-header"; import type { AverageSleepDto } from "./use-sleep-rhythm"; /** Whole hours + minutes from a minute total, for the headline figure. */ @@ -34,13 +35,11 @@ export function AverageSleepCard({ average }: { average: AverageSleepDto }) { if (average.state === "partial") { return ( - -
- - - {t("insights.sleep.average.title")} - -
+ + - -
- - - {t("insights.sleep.average.title")} - -
+ +
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 03bb455e6..14c0e66be 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -4,8 +4,9 @@ import { useState } from "react"; import { Clock, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { LearningGate } from "@/components/ui/learning-gate"; +import { TileHeader } from "@/components/insights/tile-header"; import type { ChronotypeDto } from "./use-sleep-rhythm"; /** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */ @@ -47,13 +48,11 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { const need = chronotype.freeNightsCounted + chronotype.freeNightsUntilReady; return ( - -
- - - {t("insights.sleep.chronotype.title")} - -
+ + - -
- - - {t("insights.sleep.chronotype.title")} - -
+ + {/* Uniform with the sleep-debt tile: a bold primary value (the band) at diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index cb246d5d5..8799ad0e2 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -3,8 +3,9 @@ import { Moon } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { InfoHint } from "@/components/ui/info-hint"; +import { TileHeader } from "@/components/insights/tile-header"; import { LearningGate } from "@/components/ui/learning-gate"; import type { SleepDebtDto } from "./use-sleep-rhythm"; @@ -32,20 +33,20 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { if (debt.state === "partial") { return ( - -
- - - {t("insights.sleep.debt.title")} - - {/* v1.25.0 — when the active source on the user's sleep-debt ladder - is our own COMPUTED engine (every user today, until a provider - ships a native debt), explain what the figure means and that it - differs from a wearable's native number. */} - {debt.source === "COMPUTED" ? ( - - ) : null} -
+ + {/* v1.25.0 — when the active source on the user's sleep-debt ladder + is our own COMPUTED engine (every user today, until a provider + ships a native debt), explain what the figure means and that it + differs from a wearable's native number. */} + + ) : undefined + } + /> - -
- - - {t("insights.sleep.debt.title")} - - {/* v1.25.0 — the resolved full-debt card is the common case and the - place the figure most needs context: explain the COMPUTED engine - and that it differs from a wearable's native number here too, not - only in the still-learning `partial` state above. */} - {debt.source === "COMPUTED" ? ( - - ) : null} -
+ + {/* v1.25.0 — the resolved full-debt card is the common case and the + place the figure most needs context: explain the COMPUTED engine + and that it differs from a wearable's native number here too, not + only in the still-learning `partial` state above. */} + + ) : undefined + } + />
diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index 5d0428e64..e31c032ed 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -30,13 +30,23 @@ interface TileHeaderProps { * Leading glyph. Accepts a Lucide icon component (or any component that * takes a `className`) so the caller passes the component itself * (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size - * and colour so every tile header matches. + * and colour so every tile header matches. Optional: a text-only tile + * header (chart cards whose title needs no glyph) omits it and still + * gets the canonical row + `CardTitle` treatment. */ - icon: ComponentType<{ className?: string }>; + icon?: ComponentType<{ className?: string }>; /** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */ title: ReactNode; /** Optional trailing affordance pinned to the right edge of the row. */ right?: ReactNode; + /** + * The ONE sanctioned compact variant: icon `h-4 w-4`, title `text-sm` — + * for dense correlation/stat tiles that sit several to a row where the + * full `text-base` header visually overpowers the tile body. Everything + * else stays on the default; do not express a third size via + * `titleClassName`. + */ + size?: "default" | "sm"; className?: string; titleClassName?: string; /** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */ @@ -47,6 +57,7 @@ export function TileHeader({ icon: Icon, title, right, + size = "default", className, titleClassName, id, @@ -58,8 +69,19 @@ export function TileHeader({ > {/* Icon and heading share the foreground colour — the calm, high-contrast read the "Einschätzung" card established. */} -
) : isError ? ( -

- {t("labs.biomarker.loadError")} -

+ void refetch()} + /> ) : markers.length === 0 ? ( } diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 8f1c7ca47..3c009438a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -16,6 +16,7 @@ import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; @@ -248,21 +249,13 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {description}

- - -

{t("labs.loadError")}

- -
-
+ { + if (markerError) void refetchMarker(); + if (listError) void refetchList(); + }} + />
); } @@ -353,13 +346,13 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {isLoading ? ( - + ) : readings.length === 0 ? ( - + } title={t("labs.detail.emptyTitle")} @@ -376,7 +369,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { // Qualitative marker — no numeric strip / chart. Show the latest // result text on its own simple line, no unit, no range badge. - +

{t("labs.detail.latestResult")}

@@ -394,7 +387,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { icon={FlaskConical} /> - + - {t("labs.loadError")} -

+ void refetch()} + /> ); } diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index eb7f17fd5..7cf66557b 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -28,6 +28,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Table, TableBody, @@ -172,7 +173,7 @@ export function IntakeHistoryListV2({ const offset = page * pageSize; - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.medicationIntakeList(medicationId, { sortBy, sortDir, @@ -243,9 +244,10 @@ export function IntakeHistoryListV2({
) : isError ? ( -

- {t("medications.loadFailed")} -

+ void refetch()} + /> ) : events.length === 0 ? ( +
{href ? ( void; + /** Submit the completed check-in (+ the optional PHQ-9 follow-up). */ + onSubmit: (items: number[], functionalDifficulty?: number) => void; /** Return to the landing (abandons the in-progress check-in). */ onBack: () => void; isPending: boolean; @@ -57,15 +57,28 @@ export function CheckInWizard({ const { t } = useTranslations(); const key = lower(instrument); const itemCount = INSTRUMENTS[instrument].itemCount; + // v1.27.8 — the PHQ-9 asks its validated functional-impairment + // follow-up as a regular LAST question (10 of 10) instead of a + // separate review-step widget. It is OPTIONAL per the instrument + // (answering is not required to submit) and never scores into the + // total — the value rides the existing `functionalDifficulty` API + // field. GAD-7 has no such item and keeps its 7 steps. + const hasFunctionalStep = instrument === "PHQ9"; + const totalSteps = itemCount + (hasFunctionalStep ? 1 : 0); const [step, setStep] = useState(1); const [items, setItems] = useState(() => Array(itemCount).fill(UNANSWERED), ); + const [functional, setFunctional] = useState(UNANSWERED); - const onLast = isLastStep(step, itemCount); + const onFunctionalStep = hasFunctionalStep && step === itemCount + 1; + const onLast = isLastStep(step, totalSteps); const questionIndex = step - 1; // 0-based item index for the current question const complete = isComplete(items); + const questionLabel = onFunctionalStep + ? t("mentalHealth.functionalTitle") + : t(`mentalHealth.items.${key}.${step}`); // a11y: announce each step by moving focus to its heading (the question // text). Mirrors the medication dialog's per-step title focus so a @@ -77,6 +90,13 @@ export function CheckInWizard({ }, [step]); function selectAnswer(value: number) { + if (onFunctionalStep) { + // Optional follow-up: selecting never advances (it IS the last + // step) and tapping the selected option again clears it — the + // answer stays skippable right up to submit. + setFunctional((prev) => (prev === value ? UNANSWERED : value)); + return; + } setItems((prev) => { const next = [...prev]; next[questionIndex] = value; @@ -85,7 +105,7 @@ export function CheckInWizard({ // Forward-on-select: advance to the next question. On the last question // the selection stays visible and the footer submit arms — submitting is // a deliberate second tap, never a side effect of answering. - if (!onLast) setStep((s) => nextStep(s, itemCount)); + if (!onLast) setStep((s) => nextStep(s, totalSteps)); } return ( @@ -123,7 +143,7 @@ export function CheckInWizard({

{t("mentalHealth.progress", { current: step, - total: itemCount, + total: totalSteps, })}

- {t(`mentalHealth.items.${key}.${step}`)} + {questionLabel}

+ {onFunctionalStep && ( +

+ {t("mentalHealth.functionalOptionalHint")} +

+ )}
- {SCALE.map((v) => ( - - ))} + {SCALE.map((v) => { + const selected = onFunctionalStep + ? functional === v + : items[questionIndex] === v; + return ( + + ); + })}
{isError && ( @@ -177,7 +209,7 @@ export function CheckInWizard({
) : isError ? ( -

- {t("records.allergies.loadError")} -

+ void refetch()} + /> ) : rows.length === 0 ? ( } diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 686a5dfa1..dea120160 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -28,6 +28,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; @@ -126,9 +127,10 @@ export function ConditionsManager() { return (
{query.isError ? ( -

- {t("records.conditions.loadError")} -

+ void query.refetch()} + /> ) : ( <> {field( diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 545038850..4a6cb7542 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; @@ -30,7 +31,7 @@ export function FamilyHistoryManager() { const [editing, setEditing] = useState(null); const [formFooterEl, setFormFooterEl] = useState(null); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.familyHistoryList(), queryFn: () => apiGet("/api/family-history"), }); @@ -120,9 +121,10 @@ export function FamilyHistoryManager() { ))}
) : isError ? ( -

- {t("records.family.loadError")} -

+ void refetch()} + /> ) : rows.length === 0 ? ( } diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 84a8c1e2b..a52a1d4bb 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -158,15 +158,15 @@ const WIDGET_LABEL_KEYS: Record = { /** * v1.27.7 — option labels for the hero score-ring picker. The three - * derived rings reuse their wellness-strip titles; the adherence ring - * reuses the existing "adherence (7 days)" label so the picker names - * the exact window the ring shows. + * derived rings reuse their wellness-strip titles; the dose ring names + * today's tally (the exact thing the ring shows since it moved off the + * 7-day adherence percentage). */ const SCORE_RING_LABEL_KEYS: Record = { READINESS: "insights.derived.composite.READINESS.title", RECOVERY_SCORE: "insights.derived.scores.recovery", SLEEP_SCORE: "insights.derived.composite.SLEEP_SCORE.title", - MED_COMPLIANCE: "dashboard.compliance7d", + MED_COMPLIANCE: "dashboard.hero.ringDoses", }; export function DashboardLayoutSection({ id }: { id: string }) { @@ -300,12 +300,71 @@ export function DashboardLayoutSection({ id }: { id: string }) { setDraft({ ...layout, heroVisible: value }); } + /** + * v1.27.8 — the ring choices apply INSTANTLY, outside the draft/Save + * machine the rest of the section keeps. The first cut routed ring + * toggles through the draft, and the combination of an unflushed + * draft, the server snapshot cache, and client staleness read as + * "flipping does nothing, rings appear later". The mutation PUTs the + * SERVER layout copy with only `selectedScoreRings` changed (an open + * draft's other edits stay unsent and untouched), optimistically + * updates the widgets query, and invalidates the dashboard snapshot on + * settle so the hero reflects the choice on the next visit. + */ + const ringMutation = useMutation({ + mutationFn: async (next: ScoreRingId[]) => { + if (!remote) throw new Error("layout not loaded"); + return apiPut("/api/dashboard/widgets", { + ...remote, + selectedScoreRings: next, + }); + }, + onMutate: async (next) => { + await queryClient.cancelQueries({ + queryKey: queryKeys.dashboardWidgets(), + }); + const previous = queryClient.getQueryData( + queryKeys.dashboardWidgets(), + ); + if (previous) { + queryClient.setQueryData(queryKeys.dashboardWidgets(), { + ...previous, + selectedScoreRings: next, + }); + } + return { previous }; + }, + onError: (_err, _next, context) => { + if (context?.previous) { + queryClient.setQueryData( + queryKeys.dashboardWidgets(), + context.previous, + ); + } + toast.error(t("dashboard.layoutSaveError")); + }, + onSuccess: (saved) => { + queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + queryClient.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + }, + }); + + /** Apply a new ring selection/order instantly (see `ringMutation`). */ + function applyScoreRings(next: ScoreRingId[]) { + if (!remote) return; + // Keep an open draft's ring slice in sync so a later Save of the + // OTHER layout edits can't revert the instantly-applied choice. + if (draft) setDraft({ ...draft, selectedScoreRings: next }); + ringMutation.mutate(next); + } + /** * v1.27.7 — toggle one hero score ring. Selection order is preserved * (a newly-enabled ring appends), the count is capped at * `MAX_SELECTED_SCORE_RINGS` (the remaining switches disable at the - * cap), and the choice persists as `selectedScoreRings` on the layout - * blob through the same PUT the widget toggles use. + * cap). v1.27.8 — applies instantly via `applyScoreRings`. */ function toggleScoreRing(ringId: ScoreRingId, enabled: boolean) { if (!layout) return; @@ -315,7 +374,37 @@ export function DashboardLayoutSection({ id }: { id: string }) { ? current : [...current, ringId].slice(0, MAX_SELECTED_SCORE_RINGS) : current.filter((id) => id !== ringId); - setDraft({ ...layout, selectedScoreRings: next }); + applyScoreRings(next); + } + + /** + * v1.27.8 — reorder the selected rings (the array order IS the render + * order on the hero; the server preserves it). Same interaction pair + * as the widget rows below: drag handle + arrow buttons. + */ + function moveScoreRing(ringId: ScoreRingId, delta: -1 | 1) { + if (!layout) return; + const current = layout.selectedScoreRings ?? []; + const idx = current.indexOf(ringId); + const target = idx + delta; + if (idx < 0 || target < 0 || target >= current.length) return; + const next = [...current]; + [next[idx], next[target]] = [next[target], next[idx]]; + applyScoreRings(next); + } + + function handleRingDragEnd(event: DragEndEvent) { + if (!layout) return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const current = layout.selectedScoreRings ?? []; + const from = current.indexOf(active.id as ScoreRingId); + const to = current.indexOf(over.id as ScoreRingId); + if (from < 0 || to < 0) return; + const next = [...current]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + applyScoreRings(next); } /** @@ -480,7 +569,10 @@ export function DashboardLayoutSection({ id }: { id: string }) { module is enabled — the WIDGET_MODULE_BY_ID gating pattern; the three derived rings additionally sit behind the insights module, mirroring the derived routes. Selection caps at - MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. */} + MAX_SELECTED_SCORE_RINGS; unchecked switches disable at the cap. + v1.27.8 — choices apply instantly (no Save round) and the + selected rings reorder with the same drag-handle + arrow pair + as the widget rows below; the array order is the hero order. */} {layout && layout.heroVisible === true && (
-
- {SCORE_RING_IDS.filter((ringId) => { + {(() => { + const available = SCORE_RING_IDS.filter((ringId) => { if (modules?.[SCORE_RING_MODULE[ringId]] === false) return false; if (ringId !== "MED_COMPLIANCE" && modules?.insights === false) return false; return true; - }).map((ringId) => { - const selected = layout.selectedScoreRings ?? []; - const checked = selected.includes(ringId); - const atCap = - !checked && selected.length >= MAX_SELECTED_SCORE_RINGS; - return ( -
available.includes(ringId), + ); + const unselected = available.filter( + (ringId) => !selected.includes(ringId), + ); + const atCap = selected.length >= MAX_SELECTED_SCORE_RINGS; + const busy = ringMutation.isPending; + return ( +
+ - - {t(SCORE_RING_LABEL_KEYS[ringId])} - - toggleScoreRing(ringId, v)} - disabled={saveMutation.isPending || atCap} - aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} - data-slot="score-ring-switch" - data-ring={ringId} - /> -
- ); - })} -
+ + {selected.map((ringId, index) => ( + + ))} + + + {unselected.map((ringId) => ( +
+ + {t(SCORE_RING_LABEL_KEYS[ringId])} + + toggleScoreRing(ringId, v)} + disabled={busy || atCap} + aria-label={t(SCORE_RING_LABEL_KEYS[ringId])} + data-slot="score-ring-switch" + data-ring={ringId} + /> +
+ ))} +
+ ); + })()}
)} @@ -838,3 +964,110 @@ function SortableWidgetRow({
); } + +/** + * v1.27.8 — sortable row for one SELECTED hero score ring. Mirrors the + * `SortableWidgetRow` interaction exactly (drag handle + arrow buttons + + * switch) so the two lists in this section share one sorting language; + * only the payload differs — the ring order is the `selectedScoreRings` + * array itself, applied instantly through the ring mutation. + */ +function SortableRingRow({ + ringId, + label, + index, + total, + dragHintId, + disabled, + moveUpLabel, + moveDownLabel, + dragHandleLabel, + onToggle, + onMove, +}: { + ringId: ScoreRingId; + label: string; + index: number; + total: number; + dragHintId: string; + disabled: boolean; + moveUpLabel: string; + moveDownLabel: string; + dragHandleLabel: string; + onToggle: (id: ScoreRingId, value: boolean) => void; + onMove: (id: ScoreRingId, delta: -1 | 1) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: ringId }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: prefersReducedMotion() ? "none" : transition, + }; + + return ( +
+ + + {label} + + onToggle(ringId, v)} + disabled={disabled} + aria-label={label} + data-slot="score-ring-switch" + data-ring={ringId} + /> + + +
+ ); +} diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index fb46b085c..93c0b118c 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -58,6 +58,8 @@ interface SectionState { mood: boolean; bmi: boolean; labs: boolean; + allergies: boolean; + familyHistory: boolean; } const DEFAULT_SECTIONS: SectionState = { @@ -79,6 +81,8 @@ const DEFAULT_SECTIONS: SectionState = { mood: false, // privacy default bmi: true, labs: true, + allergies: true, + familyHistory: true, }; function buildSelectionSections(s: SectionState) { @@ -106,6 +110,8 @@ function buildSelectionSections(s: SectionState) { mood: s.mood, bmi: s.bmi, labs: s.labs, + allergies: s.allergies, + familyHistory: s.familyHistory, }; } @@ -406,6 +412,16 @@ export function HealthRecordExportPanel() { checked={sections.labs} onToggle={() => toggle("labs")} /> + toggle("allergies")} + /> + toggle("familyHistory")} + />
{isPdfLike && ( diff --git a/src/lib/ai/coach/__tests__/self-context.test.ts b/src/lib/ai/coach/__tests__/self-context.test.ts index fea1fbc55..0786e6890 100644 --- a/src/lib/ai/coach/__tests__/self-context.test.ts +++ b/src/lib/ai/coach/__tests__/self-context.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); import { clampPendingQuestions, composeSelfContextText, + composeStructuredRecordsText, deriveAgeYears, PENDING_QUESTION_MAX_CHARS, type SelfContext, @@ -105,6 +106,52 @@ describe("composeSelfContextText", () => { }); }); +describe("composeStructuredRecordsText", () => { + it("stays null when both structured stores are empty", () => { + expect(composeStructuredRecordsText([], [], "en")).toBeNull(); + }); + + it("renders allergies from stored fields only (severity, reaction, non-active status)", () => { + const text = composeStructuredRecordsText( + [ + { + substance: "Penicillin", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + reaction: "Anaphylaxis", + }, + { + substance: "Lactose", + type: "INTOLERANCE", + severity: null, + status: "RESOLVED", + reaction: null, + }, + ], + [], + "en", + ); + expect(text).toContain("Penicillin (severe, reaction: Anaphylaxis)"); + expect(text).toContain("Lactose (intolerance, resolved)"); + }); + + it("renders family history with relationship labels and onset age (de)", () => { + const text = composeStructuredRecordsText( + [], + [ + { relationship: "MOTHER", condition: "Typ-2-Diabetes", ageAtOnset: 55 }, + { relationship: "FATHER", condition: "Hypertonie", ageAtOnset: null }, + ], + "de", + ); + expect(text).toContain("Familienanamnese"); + expect(text).toContain("Mutter: Typ-2-Diabetes (Beginn mit 55)"); + expect(text).toContain("Vater: Hypertonie"); + expect(text).not.toContain("Vater: Hypertonie ("); + }); +}); + describe("clampPendingQuestions", () => { it("caps at 3 questions and per-question length", () => { const long = "x".repeat(PENDING_QUESTION_MAX_CHARS + 50); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index d6f5dcd50..e94dc3bdd 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -113,6 +113,155 @@ const GENDER_LABELS: Record<"de" | "en", Record> = { en: { MALE: "male", FEMALE: "female", OTHER: "non-binary" }, }; +/** + * v1.27.x — structured-record projection (link between the Anamnese + * stores and the AI context). A user who records a penicillin allergy in + * the structured records UI — the app's most explicit input for exactly + * this — must not have a Coach that only knows the free-text answer. + * Stored fields only, rendered descriptively; the structured rows are the + * deliberate record, so the block labels them as such and they take + * precedence over the free-text answer on conflict. + */ + +export interface StructuredAllergyForPrompt { + substance: string; + type: string; + severity: string | null; + status: string; + reaction: string | null; +} + +export interface StructuredFamilyHistoryForPrompt { + relationship: string; + condition: string; + ageAtOnset: number | null; +} + +const ALLERGY_SEVERITY_LABELS: Record<"de" | "en", Record> = { + de: { + NONE: "ohne Beschwerden", + MILD: "leicht", + MODERATE: "mittel", + SEVERE: "schwer", + }, + en: { + NONE: "no symptoms", + MILD: "mild", + MODERATE: "moderate", + SEVERE: "severe", + }, +}; + +const ALLERGY_STATUS_LABELS: Record<"de" | "en", Record> = { + de: { INACTIVE: "inaktiv", RESOLVED: "abgeklungen" }, + en: { INACTIVE: "inactive", RESOLVED: "resolved" }, +}; + +const FAMILY_RELATIONSHIP_LABELS: Record< + "de" | "en", + Record +> = { + de: { + MOTHER: "Mutter", + FATHER: "Vater", + SISTER: "Schwester", + BROTHER: "Bruder", + DAUGHTER: "Tochter", + SON: "Sohn", + GRANDMOTHER_MATERNAL: "Großmutter (mütterlicherseits)", + GRANDFATHER_MATERNAL: "Großvater (mütterlicherseits)", + GRANDMOTHER_PATERNAL: "Großmutter (väterlicherseits)", + GRANDFATHER_PATERNAL: "Großvater (väterlicherseits)", + AUNT: "Tante", + UNCLE: "Onkel", + COUSIN: "Cousin/Cousine", + HALF_SIBLING: "Halbgeschwister", + OTHER: "Weitere Verwandte", + }, + en: { + MOTHER: "mother", + FATHER: "father", + SISTER: "sister", + BROTHER: "brother", + DAUGHTER: "daughter", + SON: "son", + GRANDMOTHER_MATERNAL: "maternal grandmother", + GRANDFATHER_MATERNAL: "maternal grandfather", + GRANDMOTHER_PATERNAL: "paternal grandmother", + GRANDFATHER_PATERNAL: "paternal grandfather", + AUNT: "aunt", + UNCLE: "uncle", + COUSIN: "cousin", + HALF_SIBLING: "half-sibling", + OTHER: "other relative", + }, +}; + +/** + * Compose the structured-records lines for the prompt context block. + * Descriptive, stored fields only — substance/kind/severity/reaction/ + * status for allergies, relationship/condition/age-at-onset for family + * history. Returns `null` when both lists are empty so the caller can + * skip the block. + */ +export function composeStructuredRecordsText( + allergies: StructuredAllergyForPrompt[], + familyHistory: StructuredFamilyHistoryForPrompt[], + locale: string, +): string | null { + const de = locale === "de"; + const lang = de ? "de" : "en"; + const lines: string[] = []; + + if (allergies.length > 0) { + const items = allergies.map((a) => { + const details: string[] = []; + if (a.type === "INTOLERANCE") { + details.push(de ? "Unverträglichkeit" : "intolerance"); + } + if (a.severity) { + const label = ALLERGY_SEVERITY_LABELS[lang][a.severity]; + if (label) details.push(label); + } + if (a.reaction) { + details.push((de ? "Reaktion: " : "reaction: ") + a.reaction); + } + const statusLabel = ALLERGY_STATUS_LABELS[lang][a.status]; + if (statusLabel) details.push(statusLabel); + return details.length > 0 + ? `${a.substance} (${details.join(", ")})` + : a.substance; + }); + lines.push( + (de + ? "Dokumentierte Allergien/Unverträglichkeiten (strukturierte Einträge — bei Widerspruch maßgeblich): " + : "Recorded allergies/intolerances (structured entries — authoritative on conflict): ") + + items.join("; "), + ); + } + + if (familyHistory.length > 0) { + const items = familyHistory.map((f) => { + const rel = + FAMILY_RELATIONSHIP_LABELS[lang][f.relationship] ?? f.relationship; + const onset = + f.ageAtOnset !== null + ? de + ? ` (Beginn mit ${f.ageAtOnset})` + : ` (onset at ${f.ageAtOnset})` + : ""; + return `${rel}: ${f.condition}${onset}`; + }); + lines.push( + (de + ? "Familienanamnese (strukturierte Einträge): " + : "Family history (structured entries): ") + items.join("; "), + ); + } + + return lines.length > 0 ? lines.join("\n") : null; +} + /** * Compose the merged self-context text the prompt block quotes: profile * facts first (age/gender from the User row — the single source, never @@ -194,14 +343,33 @@ export async function getSelfContextTextForUser( locale: string, ): Promise { try { - const [ctx, user] = await Promise.all([ + const [ctx, user, allergyRows, familyRows] = await Promise.all([ getSelfContextForUser(userId), prisma.user.findUnique({ where: { id: userId }, select: { dateOfBirth: true, gender: true }, }), + // v1.27.x — structured records join the context block. Live rows + // only; the free-text notes are never selected. The reaction + // ciphertext decrypts fail-closed per row below. + prisma.allergy.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { + substance: true, + type: true, + severity: true, + status: true, + reactionEncrypted: true, + }, + }), + prisma.familyHistoryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { relationship: true, condition: true, ageAtOnset: true }, + }), ]); - return composeSelfContextText( + const selfText = composeSelfContextText( ctx, { ageYears: deriveAgeYears(user?.dateOfBirth ?? null), @@ -209,6 +377,19 @@ export async function getSelfContextTextForUser( }, locale, ); + const recordsText = composeStructuredRecordsText( + allergyRows.map((r) => ({ + substance: r.substance, + type: r.type, + severity: r.severity, + status: r.status, + reaction: decryptOrNull(r.reactionEncrypted), + })), + familyRows, + locale, + ); + if (!selfText && !recordsText) return null; + return [selfText, recordsText].filter(Boolean).join("\n"); } catch { return null; } diff --git a/src/lib/dashboard/__tests__/score-rings.test.ts b/src/lib/dashboard/__tests__/score-rings.test.ts index 767011a87..8cf0491ba 100644 --- a/src/lib/dashboard/__tests__/score-rings.test.ts +++ b/src/lib/dashboard/__tests__/score-rings.test.ts @@ -1,42 +1,31 @@ /** * v1.27.7 — unit tests for the hero score-ring resolver. * - * The resolver is tested in isolation with the derived dispatcher and - * the compliance engine mocked, pinning: + * The resolver is tested in isolation with the derived dispatcher + * mocked, pinning: * - module gating (owning module + the insights gate on derived rings); * - the pass-through contract for derived scores (same resolvers the * batch route calls, no recomputation, non-`ok` → no ring); - * - the pooled 7-day adherence math (Σtaken / Σ(taken+missed), skips - * out of the denominator, empty denominator → no ring); - * - the adherence band thresholds (≥90 / ≥70 — the targets-tile rule); + * - the dose ring: today's taken/scheduled progress off the shared + * medsToday block (no ring when nothing is scheduled today, never a + * red band — pending doses are not an alert state); * - per-ring fail-softness (one throwing engine drops only its ring). */ import { describe, it, expect, vi, beforeEach } from "vitest"; const computeDerivedMetric = vi.fn(); const loadBaselineProfile = vi.fn(); -const calculateCompliance = vi.fn(); -const buildComplianceMedicationContext = vi.fn(); -const lastNonSkippedTakenAt = vi.fn(); vi.mock("@/lib/insights/derived", () => ({ computeDerivedMetric: (...a: unknown[]) => computeDerivedMetric(...a), loadBaselineProfile: (...a: unknown[]) => loadBaselineProfile(...a), isDerivedOk: (d: { status: string }) => d.status === "ok", })); -vi.mock("@/lib/analytics/compliance", () => ({ - calculateCompliance: (...a: unknown[]) => calculateCompliance(...a), - buildComplianceMedicationContext: (...a: unknown[]) => - buildComplianceMedicationContext(...a), - lastNonSkippedTakenAt: (...a: unknown[]) => lastNonSkippedTakenAt(...a), - SCHEDULE_COMPLIANCE_SELECT: { id: true }, -})); -import { buildScoreRingsBlock, complianceBandForRate } from "../score-rings"; +import { buildScoreRingsBlock, resolveDoseRing } from "../score-rings"; import type { ModuleKey } from "@/lib/modules/gate"; const NOW = new Date("2026-07-01T10:00:00.000Z"); -const TZ = "Europe/Berlin"; function moduleMap( overrides: Partial> = {}, @@ -50,54 +39,46 @@ function moduleMap( } as Record; } -const fakePrisma = { - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -} as any; +// The dose ring reads the pre-computed medsToday block — no queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const fakePrisma = {} as any; -/** A minimal medication row matching the resolver's select. */ -function med(id: string) { - return { - id, - createdAt: new Date("2026-01-01T00:00:00.000Z"), - startsOn: null, - endsOn: null, - oneShot: false, - schedules: [{ id: `${id}-s` }], - scheduleRevisions: [], - pauseEras: [], - }; -} - -function complianceResult(taken: number, missed: number, skipped = 0) { - return { - totalExpected: taken + missed + skipped, - taken, - skipped, - missed, - rate: 0, // the resolver pools counts, never this per-med rate - streak: 0, - }; +function medsToday(taken: number, scheduled: number) { + return { takenToday: taken, scheduledToday: scheduled }; } beforeEach(() => { vi.clearAllMocks(); loadBaselineProfile.mockResolvedValue({ userId: "user-1" }); - buildComplianceMedicationContext.mockReturnValue({ ctx: true }); - lastNonSkippedTakenAt.mockReturnValue(null); - fakePrisma.medication.findMany.mockResolvedValue([]); - fakePrisma.medicationIntakeEvent.findMany.mockResolvedValue([]); }); -describe("complianceBandForRate()", () => { - it("bands on the targets-tile thresholds (≥90 green / ≥70 yellow / else red)", () => { - expect(complianceBandForRate(100)).toBe("green"); - expect(complianceBandForRate(90)).toBe("green"); - expect(complianceBandForRate(89)).toBe("yellow"); - expect(complianceBandForRate(70)).toBe("yellow"); - expect(complianceBandForRate(69)).toBe("red"); - expect(complianceBandForRate(0)).toBe("red"); +describe("resolveDoseRing()", () => { + it("renders today's progress with the taken/scheduled pair", () => { + expect(resolveDoseRing(medsToday(1, 3))).toEqual({ + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }); + }); + + it("goes green only when every scheduled dose is taken — never red", () => { + expect(resolveDoseRing(medsToday(3, 3))?.band).toBe("green"); + expect(resolveDoseRing(medsToday(0, 3))?.band).toBe("yellow"); + }); + + it("no doses scheduled today → no ring (never a hollow 100%)", () => { + expect(resolveDoseRing(medsToday(0, 0))).toBeNull(); + expect(resolveDoseRing(null)).toBeNull(); + }); + + it("clamps a taken overshoot (extra manual logs) to the scheduled count", () => { + expect(resolveDoseRing(medsToday(5, 3))).toEqual({ + id: "MED_COMPLIANCE", + score: 100, + band: "green", + doses: { taken: 3, scheduled: 3 }, + }); }); }); @@ -106,15 +87,14 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, [], moduleMap(), NOW, + medsToday(1, 3), ); expect(rings).toEqual([]); expect(loadBaselineProfile).not.toHaveBeenCalled(); expect(computeDerivedMetric).not.toHaveBeenCalled(); - expect(fakePrisma.medication.findMany).not.toHaveBeenCalled(); }); it("drops rings whose owning module is disabled", async () => { @@ -125,10 +105,10 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "SLEEP_SCORE"], moduleMap({ recovery: false }), NOW, + null, ); expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 80, band: "green" }]); expect(computeDerivedMetric).toHaveBeenCalledTimes(1); @@ -137,20 +117,25 @@ describe("buildScoreRingsBlock() — selection + module gating", () => { ); }); - it("the insights gate drops derived rings but keeps the adherence ring", async () => { - fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); - calculateCompliance.mockReturnValue(complianceResult(9, 1)); + it("the insights gate drops derived rings but keeps the dose ring", async () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "MED_COMPLIANCE"], moduleMap({ insights: false }), NOW, + medsToday(2, 2), ); expect(computeDerivedMetric).not.toHaveBeenCalled(); expect(loadBaselineProfile).not.toHaveBeenCalled(); - expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 90, band: "green" }]); + expect(rings).toEqual([ + { + id: "MED_COMPLIANCE", + score: 100, + band: "green", + doses: { taken: 2, scheduled: 2 }, + }, + ]); }); }); @@ -165,10 +150,10 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["RECOVERY_SCORE", "READINESS"], moduleMap(), NOW, + null, ); expect(rings).toEqual([ { id: "RECOVERY_SCORE", score: 38, band: "red" }, @@ -186,10 +171,10 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["SLEEP_SCORE"], moduleMap(), NOW, + null, ); expect(rings).toEqual([]); }); @@ -204,66 +189,11 @@ describe("buildScoreRingsBlock() — derived rings", () => { const rings = await buildScoreRingsBlock( fakePrisma, "user-1", - TZ, ["READINESS", "SLEEP_SCORE"], moduleMap(), NOW, + null, ); expect(rings).toEqual([{ id: "SLEEP_SCORE", score: 55, band: "yellow" }]); }); }); - -describe("buildScoreRingsBlock() — pooled 7-day adherence", () => { - it("pools Σtaken / Σ(taken+missed) across medications; skips stay out", async () => { - fakePrisma.medication.findMany.mockResolvedValue([med("m1"), med("m2")]); - calculateCompliance - .mockReturnValueOnce(complianceResult(6, 1, 2)) // m1: skips ignored - .mockReturnValueOnce(complianceResult(2, 3)); - const rings = await buildScoreRingsBlock( - fakePrisma, - "user-1", - TZ, - ["MED_COMPLIANCE"], - moduleMap(), - NOW, - ); - // (6+2) / (6+1+2+3) = 8/12 = 66.67 → 67, red band. - expect(rings).toEqual([{ id: "MED_COMPLIANCE", score: 67, band: "red" }]); - // The 7-day window rides the canonical engine call. - expect(calculateCompliance).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - 7, - expect.anything(), - expect.objectContaining({ now: NOW }), - ); - }); - - it("no active non-PRN medication → no ring", async () => { - fakePrisma.medication.findMany.mockResolvedValue([]); - const rings = await buildScoreRingsBlock( - fakePrisma, - "user-1", - TZ, - ["MED_COMPLIANCE"], - moduleMap(), - NOW, - ); - expect(rings).toEqual([]); - expect(fakePrisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); - }); - - it("zero expected doses in the window → no ring (never a hollow 100%)", async () => { - fakePrisma.medication.findMany.mockResolvedValue([med("m1")]); - calculateCompliance.mockReturnValue(complianceResult(0, 0, 0)); - const rings = await buildScoreRingsBlock( - fakePrisma, - "user-1", - TZ, - ["MED_COMPLIANCE"], - moduleMap(), - NOW, - ); - expect(rings).toEqual([]); - }); -}); diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 656b147eb..4be9e7e8c 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -1196,12 +1196,14 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); expect(buildScoreRingsBlock).toHaveBeenCalledTimes(1); - const [, userId, tz, selected, modules] = + const [, userId, selected, modules, , medsToday] = buildScoreRingsBlock.mock.calls[0]; expect(userId).toBe("user-1"); - expect(tz).toBe("Europe/Berlin"); expect(selected).toEqual(["MED_COMPLIANCE"]); expect(modules).toEqual(moduleMap()); + // The dose ring reads the SAME medsToday block the snapshot ships — + // one builder call, no second tally. + expect(medsToday).toEqual(snap.medsToday); expect(snap.scoreRings).toEqual([ { id: "MED_COMPLIANCE", score: 92, band: "green" }, ]); @@ -1222,7 +1224,7 @@ describe("buildDashboardSnapshot — scoreRings wire", () => { }), ); - const [, , , selected] = buildScoreRingsBlock.mock.calls[0]; + const [, , selected] = buildScoreRingsBlock.mock.calls[0]; expect(selected).toEqual(["READINESS", "SLEEP_SCORE"]); }); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index c768586ce..8e12f1d52 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -606,7 +606,10 @@ describe("rung 8 — briefing", () => { NOW, ); expect(verdict.variant).toBe("briefing"); - expect(verdict.values).toEqual({ headline: WATCH_FINDING.headline }); + expect(verdict.values).toEqual({ + headline: WATCH_FINDING.headline, + sourceMetric: WATCH_FINDING.sourceMetric, + }); expect(verdict.cta).toEqual({ kind: "link", href: "/insights" }); }); @@ -623,7 +626,10 @@ describe("rung 8 — briefing", () => { NOW, ); expect(verdict.variant).toBe("briefing"); - expect(verdict.values).toEqual({ headline: FRESH_FINDING.headline }); + expect(verdict.values).toEqual({ + headline: FRESH_FINDING.headline, + sourceMetric: FRESH_FINDING.sourceMetric, + }); }); it("skips a STALE briefing even in ready-shaped delivery", () => { diff --git a/src/lib/dashboard/score-rings.ts b/src/lib/dashboard/score-rings.ts index 26bee67e3..b688acd9c 100644 --- a/src/lib/dashboard/score-rings.ts +++ b/src/lib/dashboard/score-rings.ts @@ -2,8 +2,8 @@ * v1.27.7 — hero score-ring resolution for the dashboard snapshot. * * Resolves the user-selected `selectedScoreRings` (max 3, closed - * `SCORE_RING_IDS` set) into `{ id, score, band }` entries the hero - * renders next to the health-score ring: + * `SCORE_RING_IDS` set) into ring entries the hero renders next to the + * health-score ring: * * - `READINESS` / `RECOVERY_SCORE` / `SLEEP_SCORE` call the SAME * `computeDerivedMetric` dispatcher the `/api/insights/derived` @@ -11,13 +11,19 @@ * `loadBaselineProfile`) — no score logic is re-derived here, and a * non-`ok` compute (insufficient data, engine gate) simply yields no * ring, mirroring the wellness strip's self-gating. - * - `MED_COMPLIANCE` is the pooled 7-day adherence across active - * non-PRN medications through the canonical `calculateCompliance` - * engine (the exact per-medication pattern the targets tile and the - * health-score pillar use): per-med `ComplianceResult`s are pooled - * as Σtaken / Σ(taken + missed) — skips stay out of the denominator, - * matching the per-medication rate semantics — and banded on the - * same ≥90 / ≥70 thresholds the targets-tile classification carries. + * - `MED_COMPLIANCE` is TODAY's dose progress from the snapshot's own + * `medsToday` block (takenToday / scheduledToday) — the same numbers + * the old hero dose row carried and the native client shows. The + * v1.27.7 first cut showed the pooled 7-day adherence percentage + * here; a percentage on a ring next to "today" scores read as a + * mystery number, so the ring now answers the question the hero + * actually poses: how many of today's doses are done. `score` stays + * the 0..100 progress (rounded) for wire compatibility; the additive + * `doses` field carries the taken/scheduled pair for the "1/3" + * display. No doses scheduled today → no ring (self-gating). The + * band is progress semantics — `green` once every scheduled dose is + * taken, `yellow` while doses remain — never `red`: pending doses in + * the morning are not an alert state. * * Module gating rides the client-safe `SCORE_RING_MODULE` map in * `@/lib/dashboard-layout` (mirroring the derived routes' @@ -41,21 +47,27 @@ import { isDerivedOk, type DerivedMetricId, } from "@/lib/insights/derived"; -import { - buildComplianceMedicationContext, - calculateCompliance, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; export type ScoreRingBand = "green" | "yellow" | "red"; -/** One resolved hero ring — score + band only, no component breakdown. */ +/** One resolved hero ring. */ export interface DashboardScoreRing { id: ScoreRingId; - /** The 0..100 score (rounded). */ + /** The 0..100 score (rounded). For `MED_COMPLIANCE`: today's dose progress. */ score: number; band: ScoreRingBand; + /** + * `MED_COMPLIANCE` only — today's dose tally behind the progress + * score, for the "taken/scheduled" ring display. Additive; absent on + * the derived score rings. + */ + doses?: { taken: number; scheduled: number }; +} + +/** The medsToday slice the dose ring reads (subset of `MedsTodayBlock`). */ +export interface MedsTodayForRings { + takenToday: number; + scheduledToday: number; } /** The derived-registry id behind each derived ring (identity mapping). */ @@ -65,18 +77,6 @@ const DERIVED_RING_METRIC: Partial> = { SLEEP_SCORE: "SLEEP_SCORE", }; -/** - * Adherence % → band, on the thresholds the targets tile's - * MEDICATION_COMPLIANCE classification already uses (≥90 "very good" / - * ≥70 "good" / below "low"), so the ring and the targets surface can - * never disagree on colour for the same rate. - */ -export function complianceBandForRate(rate: number): ScoreRingBand { - if (rate >= 90) return "green"; - if (rate >= 70) return "yellow"; - return "red"; -} - /** The compute payload slice every derived score ring reads. */ interface ScoreBandValue { score: number; @@ -84,101 +84,25 @@ interface ScoreBandValue { } /** - * Pooled 7-day medication adherence, or `null` when nothing was expected - * (no active non-PRN medication, or no due slot in the window) — the - * ring self-gates instead of asserting a hollow 100%. + * Today's dose-progress ring from the snapshot's `medsToday` block, or + * `null` when nothing is scheduled today — the ring self-gates instead + * of asserting a hollow 100%. */ -async function resolveMedComplianceRing( - prisma: PrismaClient, - userId: string, - userTz: string, - now: Date, -): Promise { - const medications = await prisma.medication.findMany({ - // As-needed (PRN) medications carry no expected doses and are - // excluded from every compliance rate (the v1.16.11 rule). - where: { userId, active: true, asNeeded: false }, - select: { - id: true, - createdAt: true, - startsOn: true, - endsOn: true, - oneShot: true, - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // Archived schedule eras + pause eras so a past day scores against - // the schedule that was live then and paused days drop out of the - // denominator — the same context every compliance caller threads. - scheduleRevisions: { - orderBy: { validFrom: "asc" }, - select: { - id: true, - validFrom: true, - validUntil: true, - payload: true, - supersededByRevisionId: true, - }, - }, - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }); - if (medications.length === 0) return null; - - // 30 days of intake events even though the rate window is 7: rolling / - // interval cadences anchor `nextDue` off the last non-skipped take, - // which can sit before the window start (the health-score pillar - // fetches the same margin). - const since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - deletedAt: null, - medicationId: { in: medications.map((m) => m.id) }, - scheduledFor: { gte: since, lte: now }, - }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - }, - }); - const eventsByMed = new Map(); - for (const ev of intakeEvents) { - const list = eventsByMed.get(ev.medicationId); - if (list) list.push(ev); - else eventsByMed.set(ev.medicationId, [ev]); - } - - let taken = 0; - let missed = 0; - for (const med of medications) { - const events = eventsByMed.get(med.id) ?? []; - const medicationContext = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(events), - userTz, - ); - const result = calculateCompliance( - events, - med.schedules, - 7, - med.createdAt, - { - now, - medicationContext, - }, - ); - taken += result.taken; - missed += result.missed; - } - - const denominator = taken + missed; - if (denominator === 0) return null; - const rate = Math.min(100, Math.round((taken / denominator) * 100)); +export function resolveDoseRing( + medsToday: MedsTodayForRings | null, +): DashboardScoreRing | null { + if (!medsToday) return null; + const scheduled = medsToday.scheduledToday; + if (!Number.isFinite(scheduled) || scheduled <= 0) return null; + const taken = Math.max(0, Math.min(medsToday.takenToday, scheduled)); + const score = Math.round((taken / scheduled) * 100); return { id: "MED_COMPLIANCE", - score: rate, - band: complianceBandForRate(rate), + score, + // Progress semantics, not adherence judgment: green when done, + // yellow while doses remain. Pending morning doses are not "red". + band: taken >= scheduled ? "green" : "yellow", + doses: { taken, scheduled }, }; } @@ -191,10 +115,10 @@ async function resolveMedComplianceRing( export async function buildScoreRingsBlock( prisma: PrismaClient, userId: string, - userTz: string, selected: ScoreRingId[], modules: Record, now: Date, + medsToday: MedsTodayForRings | null, ): Promise { const eligible = selected.filter((id) => { if (!(SCORE_RING_IDS as readonly string[]).includes(id)) return false; @@ -218,7 +142,7 @@ export async function buildScoreRingsBlock( eligible.map(async (id): Promise => { try { if (id === "MED_COMPLIANCE") { - return await resolveMedComplianceRing(prisma, userId, userTz, now); + return resolveDoseRing(medsToday); } const metric = DERIVED_RING_METRIC[id]; if (!metric || !profile) return null; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 1b26e6bdd..424177dc9 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -1079,6 +1079,14 @@ export async function buildDashboardSnapshot( (options.modules ?? (() => resolveModuleMap(user.id)))(), ); + // Fast phase — projection-backed today tally + earliest next-due. Held + // as a shared promise (the `modulesPromise` pattern) so the score-ring + // task can read today's dose tally inside the same `Promise.all` + // without a second builder call. + const medsTodayPromise = time("medsToday", () => + buildMedsTodayBlock(prisma, user.id, userTz, now), + ); + const [ slimRaw, moodRaw, @@ -1098,21 +1106,20 @@ export async function buildDashboardSnapshot( ) : Promise.resolve(null), time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), + medsTodayPromise, modulesPromise, // v1.27.7 — the selected hero score rings, resolved through the - // same engines the derived batch route calls (+ the canonical - // compliance engine for MED_COMPLIANCE). Fail-soft: a throwing - // resolver yields no rings, never a sunk snapshot. + // same engines the derived batch route calls; the dose ring reads + // today's tally off the shared medsToday block. Fail-soft: a + // throwing resolver yields no rings, never a sunk snapshot. time("scoreRings", async () => (options.scoreRings ?? buildScoreRingsBlock)( prisma, user.id, - userTz, storedLayout.selectedScoreRings ?? [], await modulesPromise, now, + await medsTodayPromise, ), ).catch(() => [] as DashboardScoreRing[]), ]); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index ef5b255aa..dbeb9946e 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -273,7 +273,10 @@ export function resolveDashboardVerdict( const picked = findings.find((f) => f.tone === "watch") ?? findings[0]; return { variant: "briefing", - values: { headline: picked.headline }, + // `sourceMetric` rides along so the hero can deep-link the + // sentence to the picked finding's metric sub-page (METRIC_HREF); + // renderers that don't know the field ignore it. + values: { headline: picked.headline, sourceMetric: picked.sourceMetric }, cta: { kind: "link", href: "/insights" }, }; } diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 112084178..b129d7ac7 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -29,6 +29,7 @@ import type { } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { readNote } from "@/lib/crypto/note-cipher"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; @@ -320,6 +321,46 @@ export interface DoctorReportData { /** ISO resolution instant, or null while ongoing. */ resolvedAt: string | null; }> | null; + /** + * v1.27.x — structured allergy / intolerance records. Reference data, + * not time-windowed: populated whenever the `allergies` section toggle + * is ON (default) and the user recorded at least one live row. The + * reaction description is decrypted fail-soft per row (a key-rotation + * gap omits the value, never fails the report); the free-text note is + * NEVER read here — same stance as the illness journal. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + allergies?: Array<{ + /** Substance / allergen label as the user recorded it. */ + substance: string; + /** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */ + category: string; + /** Kind (ALLERGY / INTOLERANCE). */ + type: string; + /** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */ + severity: string | null; + /** Status (ACTIVE / INACTIVE / RESOLVED). */ + status: string; + /** Decrypted reaction description, or null (unset or key gap). */ + reaction: string | null; + }> | null; + /** + * v1.27.x — structured family-history records. Reference data, not + * time-windowed: populated whenever the `familyHistory` section toggle + * is ON (default) and the user recorded at least one live row. Labels + + * onset age only — the free-text note is never read here. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + familyHistory?: Array<{ + /** Relationship (MOTHER / FATHER / SISTER / …). */ + relationship: string; + /** Condition label as the user recorded it. */ + condition: string; + /** The relative's age at onset (years), or null when unknown. */ + ageAtOnset: number | null; + }> | null; } /** The three persisted score types surfaced in the wellness summary. */ @@ -1410,6 +1451,58 @@ export async function collectDoctorReportData( illnessEpisodes = mapped.length > 0 ? mapped : null; } + // v1.27.x — structured allergies + family history. Reference data, not + // time-windowed (a penicillin allergy does not expire with the report + // window), so no date filter. Riding the section toggles keeps the "you + // can deselect any section" contract; both default ON — a clinical + // report without an allergy section is the riskier default. The + // reaction description decrypts fail-soft per row; the free-text notes + // are never selected, matching the illness-journal stance. + let allergies: DoctorReportData["allergies"] = null; + if (sections.allergies) { + const rows = await prisma.allergy.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { + substance: true, + category: true, + type: true, + severity: true, + status: true, + reactionEncrypted: true, + }, + }); + const mapped = rows.map((r) => { + let reaction: string | null = null; + if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) { + try { + reaction = decryptFromBytes(r.reactionEncrypted); + } catch { + reaction = null; + } + } + return { + substance: r.substance, + category: r.category, + type: r.type, + severity: r.severity, + status: r.status, + reaction, + }; + }); + allergies = mapped.length > 0 ? mapped : null; + } + + let familyHistory: DoctorReportData["familyHistory"] = null; + if (sections.familyHistory) { + const rows = await prisma.familyHistoryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { relationship: true, condition: true, ageAtOnset: true }, + }); + familyHistory = rows.length > 0 ? rows : null; + } + return { period: { days, @@ -1464,6 +1557,8 @@ export async function collectDoctorReportData( cycle, labResults, illnessEpisodes, + allergies, + familyHistory, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 59a69404c..40a06a450 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1604,6 +1604,121 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.27.x — structured allergy / intolerance records. Reference data + // (not time-windowed) the aggregator populates when the `allergies` + // toggle is ON (default) and rows exist. Stored fields only — substance, + // category, kind, severity, reaction, status — in the same calm factual + // table register as the illness section; no colour, no severity tint. + if (data.allergies && data.allergies.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.allergiesTitle"), margin, y); + y += 6; + + const allergyRows = data.allergies.map((al) => [ + al.substance, + t(`records.allergies.category.${al.category}`), + t(`records.allergies.type.${al.type}`), + al.severity ? t(`records.allergies.severity.${al.severity}`) : "—", + al.reaction ?? "—", + t(`records.allergies.status.${al.status}`), + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.allergiesColSubstance"), + t("doctorReport.allergiesColCategory"), + t("doctorReport.allergiesColKind"), + t("doctorReport.allergiesColSeverity"), + t("doctorReport.allergiesColReaction"), + t("doctorReport.allergiesColStatus"), + ], + ], + body: allergyRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + + // v1.27.x — structured family-history records. Reference data the + // aggregator populates when the `familyHistory` toggle is ON (default) + // and rows exist. Relationship + condition + age at onset only — the + // free-text note never reaches this surface. + if (data.familyHistory && data.familyHistory.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.familyHistoryTitle"), margin, y); + y += 6; + + const familyRows = data.familyHistory.map((fh) => [ + t(`records.family.relationship.${fh.relationship}`), + fh.condition, + fh.ageAtOnset !== null ? String(fh.ageAtOnset) : "—", + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.familyHistoryColRelationship"), + t("doctorReport.familyHistoryColCondition"), + t("doctorReport.familyHistoryColAgeAtOnset"), + ], + ], + body: familyRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index dcdcb3d3c..075d250ea 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -866,11 +866,20 @@ export const dashboardSnapshotResponse = z ]), score: z.number().int(), band: z.enum(["green", "yellow", "red"]), + doses: z + .object({ + taken: z.number().int(), + scheduled: z.number().int(), + }) + .optional() + .describe( + "MED_COMPLIANCE only — today's dose tally behind the progress score, for a 'taken/scheduled' ring display (e.g. 1/3). Absent on the derived score rings.", + ), }), ) .optional() .describe( - "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`), MED_COMPLIANCE as the pooled 7-day medication adherence (0..100, banded ≥90 green / ≥70 yellow / else red). Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.", + "User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is absent when no dose is scheduled today. Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.", ), briefing: z.record(z.string(), z.unknown()).nullable(), briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index f57fa136c..59cbb0950 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -92,6 +92,8 @@ describe("resolveDoctorReportPrefs", () => { sleep: false, cycle: false, labs: false, + allergies: false, + familyHistory: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); expect(out).toEqual({ ...current, mood: false }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index cd104823d..e19e93df1 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -33,6 +33,8 @@ export const doctorReportPrefsSchema = z sleep: z.boolean(), cycle: z.boolean(), labs: z.boolean(), + allergies: z.boolean(), + familyHistory: z.boolean(), }) .partial(); @@ -53,6 +55,8 @@ export interface DoctorReportPrefs { sleep: boolean; cycle: boolean; labs: boolean; + allergies: boolean; + familyHistory: boolean; } /** @@ -75,6 +79,12 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { // Lab results the user recorded for exactly this purpose — sharing // bloodwork with a clinician. ON by default, like BP / weight. labs: true, + // Structured allergy / intolerance records — the section every clinical + // intake asks for first. Reference data recorded to share, ON by default + // like labs. + allergies: true, + // Structured family history — same stance as allergies. + familyHistory: true, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 5f631c6a6..3cee53573 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -77,6 +77,11 @@ export const exportSectionsSchema = z // Structured lab results — ON by default (recorded to share with a // clinician, same stance as BP / weight). labs: z.boolean(), + // Structured allergy / intolerance records — ON by default (the + // section every clinical intake asks for first; same stance as labs). + allergies: z.boolean(), + // Structured family history — ON by default, same stance as allergies. + familyHistory: z.boolean(), }) .partial(); @@ -139,5 +144,7 @@ export function toDoctorReportPrefs( // Cycle is opt-in: only true when explicitly requested (privacy). cycle: s.cycle === true, labs: s.labs ?? fallback.labs, + allergies: s.allergies ?? fallback.allergies, + familyHistory: s.familyHistory ?? fallback.familyHistory, }; }