diff --git a/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs new file mode 100644 index 000000000..37821cfdf --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs @@ -0,0 +1,86 @@ +/** + * Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a + * or a shell primitive. Covers the three-part match, the translucent + * and inner-tile radii that stay legal, cn()/template strings, the allow + * option, and the scope gating. + */ + +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-raw-card-shell.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-card-shell", rule, { + valid: [ + { code: '', filename: APP_FILE }, + // An inner tile: another radius. + { + code: '
', + filename: APP_FILE, + }, + // The translucent hero glass and inner washes are not the card colour. + { + code: '
', + filename: APP_FILE, + }, + // No border: not a shell. + { code: '
', filename: APP_FILE }, + // The primitives under src/components/ui own the shell. + { + code: '
', + filename: "/repo/src/components/ui/trend-tile.tsx", + }, + // An allowed file. + { + code: '
', + filename: "/repo/src/components/charts/health-chart.tsx", + options: [{ allow: ["src/components/charts/health-chart.tsx"] }], + }, + // Outside the UI roots and in tests. + { + code: 'const c = "bg-card border rounded-xl";', + filename: "/repo/src/lib/x.ts", + }, + { + code: '
', + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: '
', + filename: APP_FILE, + errors: [{ messageId: "rawShell" }], + }, + { + code: 'cn("bg-card hover:bg-accent/40 block rounded-xl border p-4", className)', + filename: "/repo/src/app/insights/pulse/page.tsx", + errors: [{ messageId: "rawShell" }], + }, + { + code: "const c = `bg-card rounded-xl border-dashed ${x}`;", + filename: APP_FILE, + errors: [{ messageId: "rawShell" }], + }, + // An allow entry for another file does not cover this one. + { + code: '
', + filename: APP_FILE, + options: [{ allow: ["src/components/charts/health-chart.tsx"] }], + errors: [{ messageId: "rawShell" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs new file mode 100644 index 000000000..1ef5dd6e4 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-unlocalised-decimal.test.mjs @@ -0,0 +1,177 @@ +/** + * Unit tests for the `healthlog/no-unlocalised-decimal` rule — a displayed + * number goes through the app's formatters. Covers the four flagged idioms, + * the rounding round-trip that stays legal, the browser time zone lookup, the + * directive contract, and the scope gating. + */ + +import { describe, expect, it } from "vitest"; +import { Linter, RuleTester } from "eslint"; +import rule from "../no-unlocalised-decimal.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"; +const PAGE_FILE = "/repo/src/app/(app)/pulse/page.tsx"; + +ruleTester.run("no-unlocalised-decimal", rule, { + valid: [ + // The formatter is the sanctioned path. + { code: "const s = fmt.number(value, 1);", filename: APP_FILE }, + // Rounding, not display: the result is a number again. + { code: "const n = Number(value.toFixed(2));", filename: APP_FILE }, + { code: "const n = parseFloat(value.toFixed(2));", filename: APP_FILE }, + { + code: "const n = Number.parseFloat(value.toFixed(2));", + filename: APP_FILE, + }, + { code: "const n = +value.toFixed(2);", filename: APP_FILE }, + // A locale is passed. + { code: 'const s = n.toLocaleString("de-DE");', filename: APP_FILE }, + { + code: "const s = d.toLocaleDateString(locale, opts);", + filename: APP_FILE, + }, + { code: "const f = new Intl.NumberFormat(locale);", filename: APP_FILE }, + // The browser time zone lookup formats nothing. + { + code: "const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;", + filename: APP_FILE, + }, + // Integer arithmetic in text is fine. + { code: "const s = `${Math.round(a / b)} h`;", filename: APP_FILE }, + { code: "const s = String(Math.round(a / b));", filename: APP_FILE }, + // A tagged template is not text. + { code: "const q = sql`${a / b}`;", filename: APP_FILE }, + // Wire formats under src/app/api are not display. + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/app/api/export/route.ts", + }, + // Outside the UI roots and in tests the rule is silent. + { code: "const s = value.toFixed(2);", filename: "/repo/src/lib/x.ts" }, + { + code: "const s = value.toFixed(2);", + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: "const s = value.toFixed(1);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toFixed" } }], + }, + { + code: "const s = `${value.toFixed(1)} kg`;", + filename: PAGE_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "const s = value.toPrecision(3);", + filename: APP_FILE, + errors: [{ messageId: "fixed", data: { method: "toPrecision" } }], + }, + // Number() around something else does not launder the call. + { + code: "const s = String(Number(a) + value.toFixed(1));", + filename: APP_FILE, + errors: [{ messageId: "fixed" }], + }, + { + code: "{total.toLocaleString()}", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleDateString(undefined, { day: 'numeric' });", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const s = d.toLocaleTimeString();", + filename: APP_FILE, + errors: [{ messageId: "localeless" }], + }, + { + code: "const f = new Intl.NumberFormat();", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const f = new Intl.DateTimeFormat(undefined, { hour: '2-digit' });", + filename: APP_FILE, + errors: [{ messageId: "intlLocaleless" }], + }, + { + code: "const s = String(Math.round(v * 10) / 10);", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + { + code: "const s = `${total / count} per day`;", + filename: APP_FILE, + errors: [{ messageId: "division" }], + }, + ], +}); + +/** + * Directives resolve against the rule's registered name, which RuleTester + * does not use, so they run through a Linter with the plugin registered as + * `healthlog` — the way the flat config loads it. + */ +describe("no-unlocalised-decimal disable directives", () => { + const linter = new Linter({ configType: "flat" }); + const config = [ + { + files: ["**/*.tsx"], + plugins: { healthlog: { rules: { "no-unlocalised-decimal": rule } } }, + rules: { "healthlog/no-unlocalised-decimal": "error" }, + languageOptions: { + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, + ]; + const lint = (code) => + linter + .verify(code, config, { filename: "src/components/charts/x.tsx" }) + .map((m) => m.messageId ?? m.message); + + it("flags the site without a directive", () => { + expect(lint("const d = `${x.toFixed(1)}`;")).toEqual(["fixed"]); + }); + + it("accepts a next-line directive that gives a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal -- SVG coordinates, not text\nconst d = `${x.toFixed(1)}`;", + ), + ).toEqual([]); + }); + + it("refuses a directive without a reason", () => { + expect( + lint( + "// eslint-disable-next-line healthlog/no-unlocalised-decimal\nconst d = x.toFixed(1);", + ), + ).toEqual(["missingReason"]); + }); + + it("refuses a whole-file disable, which would hide the next site too", () => { + expect( + lint( + "const a = 1;\n/* eslint-disable healthlog/no-unlocalised-decimal -- SVG coordinates, not text */\nconst d = x.toFixed(1);", + ), + ).toEqual(["nextLineOnly"]); + }); +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 7205742f1..ef0b03df6 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -16,6 +16,11 @@ * slicing an ISO string or parsing UTC midnight. * - `no-planning-tags` — no planning-note identifier at the head of a * comment line. + * - `no-unlocalised-decimal` — a displayed number goes through the app's + * formatters, not toFixed() or a locale-less + * toLocaleString(). + * - `no-raw-card-shell` — a card surface is a or a shell + * primitive, not bg-card + border + rounded-xl. */ "use strict"; @@ -29,6 +34,8 @@ const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); const noUtcDayKey = require("./no-utc-day-key.js"); const noPlanningTags = require("./no-planning-tags.js"); +const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js"); +const noRawCardShell = require("./no-raw-card-shell.js"); module.exports = { rules: { @@ -46,5 +53,7 @@ module.exports = { "no-default-zone-literal": noDefaultZoneLiteral, "no-utc-day-key": noUtcDayKey, "no-planning-tags": noPlanningTags, + "no-unlocalised-decimal": noUnlocalisedDecimal, + "no-raw-card-shell": noRawCardShell, }, }; diff --git a/eslint-plugins/healthlog/no-raw-card-shell.js b/eslint-plugins/healthlog/no-raw-card-shell.js new file mode 100644 index 000000000..5a772c8c8 --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-card-shell.js @@ -0,0 +1,107 @@ +/** + * @fileoverview ESLint rule — a card surface is a `` (or one of the + * shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`. + * + * UI standards §1: "New surfaces compose `` (or `SettingsCard` in + * Settings)". A hand-rolled shell drifts on its own: one had `p-4` with no + * `md:p-6`, one `py-6` with no horizontal inset, one a header row of its own + * instead of `TileHeader`, and every one of them sat beside real cards whose + * padding, gap and shadow it did not share. + * + * FLAGGED, under `src/components/` and `src/app/` outside + * `src/components/ui/` and test files: one string literal whose class list + * carries `bg-card` (the opaque card colour; a translucent `bg-card/NN` is an + * inner tile or the sanctioned hero glass), a `border` utility, and + * `rounded-xl` (the card radius; `rounded-lg` / `rounded-md` are inner tiles + * and controls, §1). + * + * The files where a shell is the point (the shell primitives themselves, the + * hero identity surface, popovers, the sign-in card that renders outside the + * app shell, the chart frame its skeleton and error state share) are passed + * in the `allow` option, each with its reason in the flat config. There is no + * inline escape: a new exception is a reviewed change to that list. + * + * WHAT THIS RULE DOES NOT CATCH + * + * A shell assembled from separate literals (`cn("bg-card", "border", + * "rounded-xl")`), one painted through a CSS class, or one in another radius. + * It holds the idiom the sweep removed, not every way to draw a box. + */ + +"use strict"; + +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const EXEMPT_ROOTS = ["src/components/ui/"]; + +const BG_CARD_RE = /(?:^|\s)bg-card(?=\s|$)/; +const BORDER_RE = /(?:^|\s)border(?:-[a-z0-9]|(?=\s|$))/; +const RADIUS_RE = /(?:^|\s)rounded-xl(?=\s|$)/; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isShell(value) { + return ( + typeof value === "string" && + BG_CARD_RE.test(value) && + BORDER_RE.test(value) && + RADIUS_RE.test(value) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noRawCardShellRule = { + meta: { + type: "problem", + docs: { + description: + "Compose (or a shell primitive) instead of a hand-rolled bg-card + border + rounded-xl shell.", + }, + schema: [ + { + type: "object", + properties: { + allow: { type: "array", items: { type: "string" } }, + }, + additionalProperties: false, + }, + ], + messages: { + rawShell: + "Hand-rolled card shell (bg-card + border + rounded-xl). Compose (with TileHeader inside), SettingsCard in Settings, or TrendTile for a trend tile (UI standards §1). A deliberate exception goes into the rule's `allow` list in eslint.config.mjs with its reason.", + }, + }, + create(context) { + const filename = toPosix(context.filename ?? context.getFilename?.() ?? ""); + if (!ENFORCED_ROOTS.some((root) => filename.includes(root))) return {}; + if (EXEMPT_ROOTS.some((root) => filename.includes(root))) return {}; + if (isTestFile(filename)) return {}; + const allow = (context.options[0] && context.options[0].allow) || []; + if (allow.some((suffix) => filename.endsWith(suffix))) return {}; + + function check(node, value) { + if (isShell(value)) context.report({ node, messageId: "rawShell" }); + } + + return { + Literal(node) { + check(node, node.value); + }, + TemplateElement(node) { + check(node, node.value.cooked ?? node.value.raw); + }, + }; + }, +}; + +module.exports = noRawCardShellRule; diff --git a/eslint-plugins/healthlog/no-unlocalised-decimal.js b/eslint-plugins/healthlog/no-unlocalised-decimal.js new file mode 100644 index 000000000..51bb3f2cf --- /dev/null +++ b/eslint-plugins/healthlog/no-unlocalised-decimal.js @@ -0,0 +1,291 @@ +/** + * @fileoverview ESLint rule — a number shown to a person goes through the + * app's locale, never through the JavaScript default. + * + * `value.toFixed(1)` always prints a full stop. A German, French, Italian, + * Spanish, Dutch or Polish reader expects a comma (`37,5 °C`), and the same + * screen already shows a comma wherever the value went through + * `useFormatters()`. The mixed reading is the defect: one card says `70,5 kg`, + * the card beside it `70.5 kg`. `toLocaleString()` without a locale is the + * mirror image of the same mistake — it follows the browser, not the language + * the person picked in HealthLog. + * + * The legitimate forms: + * + * - in a component: `const fmt = useFormatters(); fmt.number(value, 1)` + * (`fmt.integer`, `fmt.percent` for the other shapes) + * - outside React: `makeFormatters(locale, …)` from `@/lib/format-locale` + * - rounding, not display: `Number(value.toFixed(2))` stays legal — the + * result is a number again and is formatted wherever it is shown + * + * FLAGGED, in `src/components/` and `src/app/` outside `src/app/api/` (wire + * formats are not display) and outside test files: + * + * - `.toFixed(…)` and `.toPrecision(…)`, unless the call is the + * argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or + * the operand of a unary `+` + * - `.toLocaleString()`, `toLocaleDateString()`, `toLocaleTimeString()` + * called with no locale or with `undefined` as the locale + * - `Intl.NumberFormat()` / `Intl.DateTimeFormat()` constructed with no + * locale or with `undefined` (reading `.resolvedOptions()` off it, the + * browser time zone lookup, formats nothing and passes) + * - a division rendered straight into text: `String(a / b)` and + * `` `${a / b}` `` — the quotient is fractional and prints with a stop + * + * A site where the stop is the point (SVG path data, a value written into a + * machine-read attribute) stays, with the reason on the line above: + * + * // eslint-disable-next-line healthlog/no-unlocalised-decimal -- + * + * The rule refuses a directive for itself without a reason, or in any form + * other than `eslint-disable-next-line`. + * + * WHAT THIS RULE DOES NOT CATCH + * + * Without type information it cannot tell a number from a string, so + * `String(weight)` or `` `${weight} kg` `` with a fractional `weight` passes, + * as does a quotient assigned to a variable first and rendered later. It + * removes the idioms that print a stop by construction, not every path to + * one. + */ + +"use strict"; + +const RULE_NAME = "healthlog/no-unlocalised-decimal"; +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; +const EXEMPT_ROOTS = ["src/app/api/"]; +const MIN_REASON_CHARS = 8; + +const FIXED_METHODS = new Set(["toFixed", "toPrecision"]); +const LOCALE_METHODS = new Set([ + "toLocaleString", + "toLocaleDateString", + "toLocaleTimeString", +]); +const INTL_CTORS = new Set(["NumberFormat", "DateTimeFormat"]); + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_ROOTS.some((root) => posix.includes(root))) return false; + if (isTestFile(posix)) return false; + return true; +} + +function propertyName(member) { + if (!member || member.type !== "MemberExpression") return null; + if (!member.computed && member.property.type === "Identifier") { + return member.property.name; + } + if (member.computed && member.property.type === "Literal") { + return String(member.property.value); + } + return null; +} + +function isUndefined(node) { + return ( + !node || + (node.type === "Identifier" && node.name === "undefined") || + (node.type === "UnaryExpression" && node.operator === "void") + ); +} + +/** Strip TS-only wrappers and parentheses-equivalents the parser keeps. */ +function unwrap(node) { + let current = node; + while ( + current && + (current.type === "TSAsExpression" || + current.type === "TSNonNullExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "ChainExpression") + ) { + current = current.expression; + } + return current; +} + +/** The call's value is turned back into a number: rounding, not display. */ +function isNumericRoundTrip(callNode) { + let child = callNode; + let parent = callNode.parent; + while ( + parent && + (parent.type === "TSAsExpression" || + parent.type === "TSNonNullExpression" || + parent.type === "ChainExpression") + ) { + child = parent; + parent = parent.parent; + } + if (!parent) return false; + if (parent.type === "UnaryExpression" && parent.operator === "+") { + return true; + } + if (parent.type !== "CallExpression" || parent.arguments[0] !== child) { + return false; + } + const callee = parent.callee; + if (callee.type === "Identifier") { + return callee.name === "Number" || callee.name === "parseFloat"; + } + return ( + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Number" && + propertyName(callee) === "parseFloat" + ); +} + +function isDivision(node) { + const inner = unwrap(node); + return !!inner && inner.type === "BinaryExpression" && inner.operator === "/"; +} + +function isIntlCtor(callee) { + return ( + callee && + callee.type === "MemberExpression" && + callee.object.type === "Identifier" && + callee.object.name === "Intl" && + INTL_CTORS.has(propertyName(callee)) + ); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noUnlocalisedDecimalRule = { + meta: { + type: "problem", + docs: { + description: + "Format a displayed number through useFormatters() / makeFormatters(), not toFixed() or a locale-less toLocaleString().", + }, + schema: [], + messages: { + fixed: + "`{{method}}()` prints a full stop in every language. Format the value with `useFormatters().number(value, digits)` (or `makeFormatters` outside React). For rounding, wrap it in `Number(…)`. If the stop is meant, say why: `// eslint-disable-next-line healthlog/no-unlocalised-decimal -- `.", + localeless: + "`{{method}}()` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + intlLocaleless: + "`Intl.{{ctor}}` without a locale follows the browser, not the language picked in HealthLog. Use `useFormatters()` or pass the active locale.", + division: + "A quotient rendered straight into text prints with a full stop. Format it with `useFormatters().number(value, digits)`.", + missingReason: + "A disable directive for healthlog/no-unlocalised-decimal must say why the full stop is right here: `-- ` after the rule name.", + nextLineOnly: + "Disable healthlog/no-unlocalised-decimal only with `eslint-disable-next-line`, one site at a time, so the next occurrence is still caught.", + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function checkDirectives() { + for (const comment of sourceCode.getAllComments()) { + const text = comment.value.trim(); + const match = /^eslint-disable(-next-line|-line)?\b([\s\S]*)$/.exec( + text, + ); + if (!match || !match[2].includes(RULE_NAME)) continue; + if (match[1] !== "-next-line") { + context.report({ + loc: { line: 1, column: 0 }, + messageId: "nextLineOnly", + }); + continue; + } + const reason = match[2].split(/\s--\s/)[1]; + if (!reason || reason.trim().length < MIN_REASON_CHARS) { + context.report({ loc: comment.loc, messageId: "missingReason" }); + } + } + } + + function checkIntl(node) { + // `Intl.DateTimeFormat().resolvedOptions().timeZone` reads the + // browser's zone; nothing is formatted, so the missing locale is right. + const readsOptionsOnly = + node.parent && + node.parent.type === "MemberExpression" && + node.parent.object === node && + propertyName(node.parent) === "resolvedOptions"; + if ( + !readsOptionsOnly && + isIntlCtor(node.callee) && + isUndefined(node.arguments[0]) + ) { + context.report({ + node, + messageId: "intlLocaleless", + data: { ctor: propertyName(node.callee) }, + }); + } + } + + return { + "Program:exit": checkDirectives, + CallExpression(node) { + const name = propertyName(node.callee); + if (name && FIXED_METHODS.has(name)) { + if (!isNumericRoundTrip(node)) { + context.report({ + node, + messageId: "fixed", + data: { method: name }, + }); + } + return; + } + if (name && LOCALE_METHODS.has(name)) { + if (isUndefined(node.arguments[0])) { + context.report({ + node, + messageId: "localeless", + data: { method: name }, + }); + } + return; + } + if ( + node.callee.type === "Identifier" && + node.callee.name === "String" && + isDivision(node.arguments[0]) + ) { + context.report({ node, messageId: "division" }); + return; + } + checkIntl(node); + }, + NewExpression: checkIntl, + TemplateLiteral(node) { + if (node.parent && node.parent.type === "TaggedTemplateExpression") { + return; + } + for (const expression of node.expressions) { + if (isDivision(expression)) { + context.report({ node: expression, messageId: "division" }); + } + } + }, + }; + }, +}; + +module.exports = noUnlocalisedDecimalRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f53b7b7d..c6aabb847 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,60 @@ const eslintConfig = defineConfig([ // scripts/strip-planning-tags.mjs; see eslint-plugins/healthlog/ // planning-tags.js for the shape and its limits. "healthlog/no-planning-tags": "error", + // A number shown to a person goes through useFormatters() (or + // makeFormatters outside React): toFixed() prints a full stop in every + // language and a locale-less toLocaleString() follows the browser + // instead of the language picked in the app. SVG path data and other + // machine-read strings carry `eslint-disable-next-line ... -- `; + // the rule refuses a directive without one. See the rule header. + "healthlog/no-unlocalised-decimal": "error", + // A card surface is a (or SettingsCard / TrendTile), never a + // hand-rolled bg-card + border + rounded-xl (UI standards §1). The + // files below are where a shell is the point; each says why. A new + // exception is a reviewed change to this list, there is no inline + // escape. See the rule header. + "healthlog/no-raw-card-shell": [ + "error", + { + allow: [ + // The shell primitives themselves. + "src/components/settings/settings-card.tsx", + "src/components/medications/medication-detail-section.tsx", + // The sanctioned identity surface of the dashboard (§1). + "src/components/daily/today-hero.tsx", + "src/components/daily/today-hero-skeleton.tsx", + // Floating surfaces: a chart tooltip, the tour popovers, the + // wizard's icon tile. + "src/components/charts/chart-tooltip.tsx", + "src/components/onboarding/tour.tsx", + "src/components/medications/wizard/medication-wizard-dialog.tsx", + // The chart frame: chart, skeleton and error state paint the + // same frame so a chart never moves when it loads or fails. + "src/components/charts/health-chart.tsx", + "src/components/charts/chart-skeleton.tsx", + "src/components/charts/chart-error-state.tsx", + "src/components/charts/medication-compliance-chart.tsx", + "src/components/insights/derived/score-trend-chart.tsx", + "src/components/insights/intraday-pulse-chart.tsx", + // Table frames: a clipped, unpadded border around a table. + "src/components/medications/medication-table.tsx", + "src/app/notifications/page.tsx", + // Surfaces that render outside the app shell: sign-in, register, + // claim, the key-mismatch gate. + "src/app/auth/login/page.tsx", + "src/app/auth/register/page.tsx", + "src/components/auth/claim-profile.tsx", + "src/app/key-mismatch-page.tsx", + // The first-run flow: full-screen steps with their own rhythm. + "src/components/onboarding/anamnesis-card.tsx", + "src/components/onboarding/baseline-fields.tsx", + "src/components/onboarding/confirm-screen.tsx", + "src/components/onboarding/done-screen.tsx", + "src/components/onboarding/first-result-screen.tsx", + "src/components/onboarding/getting-started-checklist.tsx", + ], + }, + ], }, }, // v1.28.17 — every recharts-rendering component funnels through the diff --git a/messages/de.json b/messages/de.json index 9bab26a04..bc7ac86ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." }, - "medicationsEmptyAction": "Medikament anlegen" + "medicationsEmptyAction": "Medikament anlegen", + "rangeLabel": "Zeitraum" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Verbunden", - "errorReconnect": "Fehler: neu verbinden", - "warningServerError": "Sync fehlgeschlagen", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausiert: neu verbinden", - "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", "hoursAgo": "vor {count} Std.", "daysAgo": "vor {count} d", "resumeSuccess": "Integration wieder aktiv", - "resumeError": "Wieder verbinden fehlgeschlagen", - "stale": "Verbunden, keine neuen Daten", - "stalled": "Sync gestoppt", - "pendingSetup": "Warte auf erste Daten" + "resumeError": "Wieder verbinden fehlgeschlagen" }, "integrationFreshness": { "title": "Empfangene Daten", @@ -9190,10 +9182,7 @@ "unitSteps": "Schritte", "unitBpm": "S/min", "unitMs": "ms", - "unitMood": "Pkt.", - "confidenceLow": "Geringe Sicherheit", - "confidenceMedium": "Mittlere Sicherheit", - "confidenceHigh": "Hohe Sicherheit" + "unitMood": "Pkt." }, "title": "Zyklus-Erkenntnisse", "symptomPatternsTitle": "Symptommuster nach Phase", diff --git a/messages/en.json b/messages/en.json index eb2056a98..780787051 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." }, - "medicationsEmptyAction": "Add a medication" + "medicationsEmptyAction": "Add a medication", + "rangeLabel": "Time range" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connected", - "errorReconnect": "Error: reconnect", - "warningServerError": "Sync failing", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Paused: reconnect", - "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", "hoursAgo": "{count} h ago", "daysAgo": "{count} d ago", "resumeSuccess": "Integration resumed", - "resumeError": "Reconnect failed", - "stale": "Connected, no recent data", - "stalled": "Sync stopped", - "pendingSetup": "Waiting for first data" + "resumeError": "Reconnect failed" }, "integrationFreshness": { "title": "Data received", @@ -9190,10 +9182,7 @@ "unitSteps": "steps", "unitBpm": "bpm", "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Low confidence", - "confidenceMedium": "Medium confidence", - "confidenceHigh": "High confidence" + "unitMood": "pts" }, "title": "Cycle insights", "symptomPatternsTitle": "Symptom patterns by phase", diff --git a/messages/es.json b/messages/es.json index 2596d7dd2..8782a47a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." }, - "medicationsEmptyAction": "Añadir un medicamento" + "medicationsEmptyAction": "Añadir un medicamento", + "rangeLabel": "Periodo" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Conectado", - "errorReconnect": "Error: reconectar", - "warningServerError": "Sincronización con errores", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausado: reconectar", - "notConnected": "No conectado", "justNow": "ahora mismo", "minutesAgo": "hace {count} min", "hoursAgo": "hace {count} h", "daysAgo": "hace {count} d", "resumeSuccess": "Integración reanudada", - "resumeError": "Reconexión fallida", - "stale": "Conectado, sin datos recientes", - "stalled": "Sincronización detenida", - "pendingSetup": "Esperando los primeros datos" + "resumeError": "Reconexión fallida" }, "integrationFreshness": { "title": "Datos recibidos", @@ -9190,10 +9182,7 @@ "unitSteps": "pasos", "unitBpm": "lpm", "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Confianza baja", - "confidenceMedium": "Confianza media", - "confidenceHigh": "Confianza alta" + "unitMood": "pts" }, "title": "Análisis del ciclo", "symptomPatternsTitle": "Patrones de síntomas por fase", diff --git a/messages/fr.json b/messages/fr.json index 89593958e..f2db6c1a6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." }, - "medicationsEmptyAction": "Ajouter un médicament" + "medicationsEmptyAction": "Ajouter un médicament", + "rangeLabel": "Période" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connecté", - "errorReconnect": "Erreur : reconnecter", - "warningServerError": "Échec de synchronisation", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "En pause : reconnecter", - "notConnected": "Non connecté", "justNow": "à l’instant", "minutesAgo": "il y a {count} min", "hoursAgo": "il y a {count} h", "daysAgo": "il y a {count} j", "resumeSuccess": "Intégration reprise", - "resumeError": "Échec de la reconnexion", - "stale": "Connecté, aucune donnée récente", - "stalled": "Synchronisation arrêtée", - "pendingSetup": "En attente des premières données" + "resumeError": "Échec de la reconnexion" }, "integrationFreshness": { "title": "Données reçues", @@ -9190,10 +9182,7 @@ "unitSteps": "pas", "unitBpm": "bpm", "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Confiance faible", - "confidenceMedium": "Confiance moyenne", - "confidenceHigh": "Confiance élevée" + "unitMood": "pts" }, "title": "Analyses du cycle", "symptomPatternsTitle": "Tendances des symptômes par phase", diff --git a/messages/it.json b/messages/it.json index 16190ea09..ea19c378e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." }, - "medicationsEmptyAction": "Aggiungi un farmaco" + "medicationsEmptyAction": "Aggiungi un farmaco", + "rangeLabel": "Periodo" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Connesso", - "errorReconnect": "Errore, riconnetti", - "warningServerError": "Sincronizzazione in errore", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "In pausa, riconnetti", - "notConnected": "Non connesso", "justNow": "proprio ora", "minutesAgo": "{count} min fa", "hoursAgo": "{count} h fa", "daysAgo": "{count} g fa", "resumeSuccess": "Integrazione ripresa", - "resumeError": "Riconnessione fallita", - "stale": "Connesso, nessun dato recente", - "stalled": "Sincronizzazione ferma", - "pendingSetup": "In attesa dei primi dati" + "resumeError": "Riconnessione fallita" }, "integrationFreshness": { "title": "Dati ricevuti", @@ -9190,10 +9182,7 @@ "unitSteps": "passi", "unitBpm": "bpm", "unitMs": "ms", - "unitMood": "pts", - "confidenceLow": "Affidabilità bassa", - "confidenceMedium": "Affidabilità media", - "confidenceHigh": "Affidabilità alta" + "unitMood": "pts" }, "title": "Analisi del ciclo", "symptomPatternsTitle": "Pattern dei sintomi per fase", diff --git a/messages/ko.json b/messages/ko.json index 022fde09c..3a753a8f9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2597,7 +2597,8 @@ "days90Title": "최근 90일", "daysAllLabel": "전체", "daysAllTitle": "전체 기간", - "medicationsEmptyAction": "약 추가하기" + "medicationsEmptyAction": "약 추가하기", + "rangeLabel": "기간" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "연결됨", - "errorReconnect": "오류: 다시 연결", - "warningServerError": "동기화 실패 중", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "일시중지: 다시 연결", - "notConnected": "연결 안 됨", "justNow": "방금", "minutesAgo": "{count}분 전", "hoursAgo": "{count}시간 전", "daysAgo": "{count}일 전", "resumeSuccess": "연동을 다시 시작했어요", - "resumeError": "다시 연결하지 못했어요", - "stale": "연결됨, 최근 데이터 없음", - "stalled": "동기화 멈춤", - "pendingSetup": "첫 데이터 기다리는 중" + "resumeError": "다시 연결하지 못했어요" }, "integrationFreshness": { "title": "받은 데이터", @@ -9190,10 +9182,7 @@ "unitSteps": "걸음", "unitBpm": "bpm", "unitMs": "ms", - "unitMood": "점", - "confidenceLow": "신뢰도 낮음", - "confidenceMedium": "신뢰도 보통", - "confidenceHigh": "신뢰도 높음" + "unitMood": "점" }, "title": "주기 인사이트", "symptomPatternsTitle": "단계별 증상 패턴", diff --git a/messages/nl.json b/messages/nl.json index 9a519e18a..3b80f1854 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Heatmap van therapietrouw van {start} tot {end}, {days} actieve dagen.", "complianceHeatmapEmpty": "Heatmap van therapietrouw, nog geen actieve dagen." }, - "medicationsEmptyAction": "Medicijn toevoegen" + "medicationsEmptyAction": "Medicijn toevoegen", + "rangeLabel": "Periode" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Gekoppeld", - "errorReconnect": "Fout: opnieuw koppelen", - "warningServerError": "Synchronisatie mislukt", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Gepauzeerd: opnieuw koppelen", - "notConnected": "Niet gekoppeld", "justNow": "zojuist", "minutesAgo": "{count} min geleden", "hoursAgo": "{count} u geleden", "daysAgo": "{count} d geleden", "resumeSuccess": "Koppeling hervat", - "resumeError": "Opnieuw koppelen mislukt", - "stale": "Gekoppeld, geen recente gegevens", - "stalled": "Synchronisatie gestopt", - "pendingSetup": "Wachten op eerste gegevens" + "resumeError": "Opnieuw koppelen mislukt" }, "integrationFreshness": { "title": "Ontvangen gegevens", @@ -9190,10 +9182,7 @@ "unitSteps": "stappen", "unitBpm": "bpm", "unitMs": "ms", - "unitMood": "pnt", - "confidenceLow": "Lage betrouwbaarheid", - "confidenceMedium": "Gemiddelde betrouwbaarheid", - "confidenceHigh": "Hoge betrouwbaarheid" + "unitMood": "pnt" }, "title": "Cyclusinzichten", "symptomPatternsTitle": "Symptoompatronen per fase", diff --git a/messages/pl.json b/messages/pl.json index 3c2c5f26e..78b02dc65 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2597,7 +2597,8 @@ "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." }, - "medicationsEmptyAction": "Dodaj lek" + "medicationsEmptyAction": "Dodaj lek", + "rangeLabel": "Zakres czasu" }, "chart": { "overlay": { @@ -6673,21 +6674,12 @@ } }, "integrationPill": { - "connected": "Połączono", - "errorReconnect": "Błąd: połącz ponownie", - "warningServerError": "Synchronizacja nie działa", - "failureCount": "{count}/{threshold}", - "parkedReconnect": "Wstrzymane: połącz ponownie", - "notConnected": "Nie połączono", "justNow": "przed chwilą", "minutesAgo": "{count} min temu", "hoursAgo": "{count} godz. temu", "daysAgo": "{count} dni temu", "resumeSuccess": "Integracja wznowiona", - "resumeError": "Ponowne połączenie nieudane", - "stale": "Połączono, brak nowych danych", - "stalled": "Synchronizacja zatrzymana", - "pendingSetup": "Czekam na pierwsze dane" + "resumeError": "Ponowne połączenie nieudane" }, "integrationFreshness": { "title": "Odebrane dane", @@ -9190,10 +9182,7 @@ "unitSteps": "kroki", "unitBpm": "ud./min", "unitMs": "ms", - "unitMood": "pkt", - "confidenceLow": "Niska pewność", - "confidenceMedium": "Średnia pewność", - "confidenceHigh": "Wysoka pewność" + "unitMood": "pkt" }, "title": "Analizy cyklu", "symptomPatternsTitle": "Wzorce objawów wg fazy", diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 34d100f06..c9d73875a 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -77,16 +77,10 @@ const ALLOWLIST: readonly Allowed[] = [ "Search-backed picker: the query narrows the whole document vault server-side.", }, { - file: "components/settings/share-document-picker.tsx", + file: "components/documents/document-picker-sheet.tsx", limit: 50, reason: - "Search-backed picker: the query narrows the whole document vault server-side.", - }, - { - file: "components/documents/document-link-picker.tsx", - limit: 50, - reason: - "Search-backed picker: the query narrows the whole document vault server-side.", + "Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.", }, { file: "hooks/use-mood-day.ts", diff --git a/src/__tests__/client-route-zod-guard.test.ts b/src/__tests__/client-route-zod-guard.test.ts new file mode 100644 index 000000000..e50290239 --- /dev/null +++ b/src/__tests__/client-route-zod-guard.test.ts @@ -0,0 +1,149 @@ +/** + * The documents vault and the Coach load no schema library in the browser. + * + * Both routes reached zod through a constant: the list of document kinds, the + * bulk limit, the attachment cap, the Coach data clusters, a conversation + * title length, the ordering of report leaves. Each of those sat in a schema + * module, so importing the number imported the library with it, on two of the + * heaviest client routes (up to 89 KB gzip per route). The constants live in + * zod-free modules now, re-exported by the schema modules for server code, and + * the server parses what the client sends anyway. + * + * What this walks: the static import graph from each route's `page.tsx`, with + * TypeScript's own parser. From the first `"use client"` module on, every + * module it reaches statically ships to the browser; none of them may import + * `zod`. Type-only imports are skipped (they vanish at build). A dynamic + * `import()` is skipped too: it is a separate chunk, loaded when the code + * that asks for it runs (the chart runtime is one), and not part of the + * route's first load. That is the limit of this guard, stated: a lazily + * loaded chunk may still carry zod. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, normalize } from "node:path"; +import ts from "typescript"; + +const ROOT = join(__dirname, "..", ".."); + +function resolve(spec: string, from: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith(".")) base = normalize(join(dirname(from), spec)); + else return null; + for (const ext of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const candidate = base + ext; + if (/\.tsx?$/.test(candidate) && existsSync(candidate)) return candidate; + } + return null; +} + +/** Value-level static imports and re-exports of one module. */ +function staticImports(source: string, file: string): string[] { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false); + const specs: string[] = []; + for (const node of sf.statements) { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + if (clause?.isTypeOnly) continue; + const named = clause?.namedBindings; + if ( + clause && + !clause.name && + named && + ts.isNamedImports(named) && + named.elements.length > 0 && + named.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (node.isTypeOnly) continue; + const clause = node.exportClause; + if ( + clause && + ts.isNamedExports(clause) && + clause.elements.length > 0 && + clause.elements.every((e) => e.isTypeOnly) + ) { + continue; + } + specs.push((node.moduleSpecifier as ts.StringLiteral).text); + } + } + return specs; +} + +function isClient(source: string): boolean { + return /^\s*["']use client["']/.test(source); +} + +/** Client modules reached from `entry` that import zod, with one path each. */ +function clientZodImports(entry: string): { file: string; path: string[] }[] { + const seen = new Map(); + const parent = new Map(); + const stack: [string, boolean][] = [[join(ROOT, entry), false]]; + const hits: string[] = []; + let walked = 0; + while (stack.length > 0) { + const [file, inClient] = stack.pop()!; + const prior = seen.get(file); + if (prior === true || (prior === false && !inClient)) continue; + const source = readFileSync(file, "utf8"); + const client = inClient || isClient(source); + seen.set(file, client); + walked += 1; + for (const spec of staticImports(source, file)) { + if (client && (spec === "zod" || spec.startsWith("zod/"))) { + hits.push(file); + } + const next = resolve(spec, file); + if (!next) continue; + if (!parent.has(next)) parent.set(next, file); + stack.push([next, client]); + } + } + if (walked < 20) + throw new Error(`walked only ${walked} modules from ${entry}`); + return [...new Set(hits)].map((file) => { + const path: string[] = []; + let cursor: string | undefined = file; + while (cursor && path.length < 10) { + path.push(cursor.slice(ROOT.length + 1)); + cursor = parent.get(cursor); + } + return { file: file.slice(ROOT.length + 1), path }; + }); +} + +describe("client routes without the schema library", () => { + for (const route of [ + "src/app/documents/page.tsx", + "src/app/coach/page.tsx", + "src/app/coach/conversations/page.tsx", + ]) { + it(`${route} ships no zod in its static client graph`, () => { + const hits = clientZodImports(route); + expect( + hits.map((h) => h.path.join(" <- ")), + "Move the constant the client needs into a zod-free module (see src/lib/documents/inbound-constants.ts) and re-export it from the schema module.", + ).toEqual([]); + }); + } + + it("sees a zod import where one is", () => { + // The schema module is zod by definition; walked as a client entry it + // must be reported, or the walk above proves nothing. + const probe = join(ROOT, "src/lib/validations/inbound-documents.ts"); + const source = readFileSync(probe, "utf8"); + expect(staticImports(source, probe).some((s) => s.startsWith("zod"))).toBe( + true, + ); + expect( + staticImports( + 'import type { A } from "zod";\nimport { type B } from "zod";\nimport { c } from "zod/v4";\nexport { d } from "./x";\nexport type { E } from "./y";', + "probe.ts", + ), + ).toEqual(["zod/v4", "./x"]); + }); +}); diff --git a/src/__tests__/empty-state-ratchet.test.ts b/src/__tests__/empty-state-ratchet.test.ts new file mode 100644 index 000000000..028e945b4 --- /dev/null +++ b/src/__tests__/empty-state-ratchet.test.ts @@ -0,0 +1,107 @@ +/** + * An honest no-data state goes through `EmptyState` (or `ChartEmptyState` on + * a chart body), UI standards §6. + * + * Seventy-five surfaces said "nothing here yet" in a hand-written muted + * paragraph, in five sizes and three alignments, some centred in a padded + * box, some a bare line under the header. This is a ratchet, not a ban: the + * hand-written copies that remain are counted, the ceiling below may only go + * down, and a new copy fails the run. The ones that stay are not list or + * section empty states but a field that has no value ("not recorded" in a + * profile row), where a centred block would be the wrong shape. + * + * Matched: a `

`, `

` or `` whose class list carries + * `text-muted-foreground` and whose only child is `{t("")}` with a last + * key segment that names an empty state (`empty`, `none`, `noData`, + * `noResults`, `notRecorded`, …; a `…Hint` key is a hint, not a state). + * + * Not matched (stated, not hidden): a message assembled from several nodes, + * a key built at runtime, a state without the muted colour. The guard holds + * the idiom, not every way to say nothing. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +/** + * The hand-written empty states that remain. Lower it when a site moves to + * `EmptyState`; never raise it. + */ +const CEILING = 13; + +const ELEMENT_RE = + /<(p|div|span)\b([^>]*?)>\s*\{t\(\s*"([a-zA-Z0-9_.]+)"[^)]*\)\}\s*<\/\1>/g; +const EMPTY_KEY_RE = + /(?:^|\.)[A-Za-z0-9]*?(?:empty|Empty|none|None|noData|NoData|noResults|NoResults|notRecorded|NotRecorded|noSeries|NoSeries|noReminder|NoReminder|noMedications|noSymptoms|noTimes)[A-Za-z0-9]*$/; + +function findHandWrittenEmptyStates( + files: { path: string; source: string }[], +): string[] { + const hits: string[] = []; + for (const { path, source } of files) { + for (const match of source.matchAll(ELEMENT_RE)) { + const [, , attrs, key] = match; + if (!attrs.includes("text-muted-foreground")) continue; + if (!EMPTY_KEY_RE.test(key) || key.endsWith("Hint")) continue; + hits.push(`${path}: ${key}`); + } + } + return hits; +} + +describe("empty states go through EmptyState (ratchet)", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.startsWith("components/ui/") && + !p.includes("__tests__/"), + ) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the UI tree", () => { + expect(files.length).toBeGreaterThan(800); + }); + + it("adds no hand-written empty state", () => { + const hits = findHandWrittenEmptyStates(files); + expect( + hits.length, + `${hits.length} hand-written empty states, ceiling ${CEILING}. Render the new one through (or ChartEmptyState on a chart body).\n${hits.join("\n")}`, + ).toBeLessThanOrEqual(CEILING); + }); + + it("has its ceiling lowered when a site moves over", () => { + const hits = findHandWrittenEmptyStates(files); + expect( + hits.length, + `Only ${hits.length} hand-written empty states remain: lower CEILING to ${hits.length}.`, + ).toBe(CEILING); + }); + + it("recognises the idiom it exists to count", () => { + expect( + findHandWrittenEmptyStates([ + { + path: "components/a.tsx", + source: + '

{t("admin.aiQuality.empty")}

', + }, + { + path: "components/b.tsx", + source: + '

{t("cycle.sheet.emptyHint")}

', + }, + { + path: "components/c.tsx", + source: '', + }, + ]), + ).toEqual(["components/a.tsx: admin.aiQuality.empty"]); + }); +}); diff --git a/src/__tests__/inline-action-guard.test.ts b/src/__tests__/inline-action-guard.test.ts new file mode 100644 index 000000000..82b6fccba --- /dev/null +++ b/src/__tests__/inline-action-guard.test.ts @@ -0,0 +1,121 @@ +/** + * A small text action inside a line of content goes through `InlineAction` + * (`src/components/ui/inline-action.tsx`). + * + * Thirty-odd hand-rolled copies of the same "underlined small text link" + * carried three different focus rings, none at all on half of them, and a + * 16 px touch target on a phone. The primitive owns the 44 px floor on a + * coarse pointer and the ring; this guard keeps a new copy from appearing + * beside it. + * + * Matched: a `', + }, + { + path: "components/z.tsx", + source: 'Go', + }, + ]; + expect(findHandRolledInlineActions(planted)).toEqual([ + "components/x.tsx:Link", + "components/y.tsx:button", + ]); + }); + + it("is used where the copies were", () => { + const users = files.filter((f) => f.source.includes("` literal from appearing outside the places where a + * different heading is the point. + * + * Matched: an ` = { + "components/ui/page-header.tsx": "the primitive", + // Hero identity surfaces, not page titles. + "components/insights/hero-strip.tsx": "insights hero greeting", + "components/insights/coach-panel/coach-hero.tsx": "Coach hero", + "components/insights/coach-panel/coach-conversation.tsx": + "screen-reader title while the hero is folded away", + // The first-run flow: full-screen steps with their own focus management. + "components/onboarding/step-heading.tsx": "first-run step heading", + // Outside the app shell: sign-in, register, claim, invite, the share-link + // gate, the key-mismatch gate. + "app/auth/login/page.tsx": "sign-in card", + "app/auth/register/page.tsx": "register card", + "components/auth/claim-profile.tsx": "claim card", + "app/invite/[token]/page.tsx": "invalid invite screen", + "components/clinician/share-unlock-gate.tsx": "share-link unlock gate", + "app/key-mismatch-page.tsx": "key-mismatch gate", + // Error and not-found screens. + "app/not-found.tsx": "not-found screen", + "app/c/[token]/not-found.tsx": "share-link not-found screen", + "app/c/error.tsx": "share-link error screen", + "app/global-error.tsx": "renders without the stylesheet", + "components/error-details.tsx": "error boundary", + // Prose pages (the prose H1 tier, §5). + "app/about/page.tsx": "prose page", + "app/privacy/page.tsx": "prose page", + // Mention an H1 in a comment only. + "app/insights/ecg/page.tsx": "comment", + "components/admin/reminders-section.tsx": "comment", + "components/charts/health-chart.tsx": "comment", + "components/insights/coach-panel/coach-top-bar-trail.tsx": "comment", + "components/insights/ecg-section.tsx": "comment", + "components/measurement-reminders/vorsorge-section.tsx": "comment", + "components/settings/settings-shell.tsx": "comment", +}; + +describe("page titles go through PageHeader", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (p) => + p.endsWith(".tsx") && + (p.startsWith("components/") || p.startsWith("app/")) && + !p.includes("__tests__/"), + ); + const withH1 = files.filter((p) => + readFileSync(join(SRC, p), "utf8").includes(" { + expect(withH1.length).toBeGreaterThan(10); + }); + + it("has no hand-rolled H1 outside the allowlist", () => { + expect(withH1.filter((p) => !(p in ALLOWED))).toEqual([]); + }); + + it("keeps every allowlist entry live", () => { + const set = new Set(withH1); + for (const key of Object.keys(ALLOWED)) + expect(set.has(key), key).toBe(true); + }); +}); diff --git a/src/__tests__/score-pill-guard.test.ts b/src/__tests__/score-pill-guard.test.ts new file mode 100644 index 000000000..e77314137 --- /dev/null +++ b/src/__tests__/score-pill-guard.test.ts @@ -0,0 +1,98 @@ +/** + * A grade chip goes through `ScorePill` (`src/components/ui/score-pill.tsx`). + * + * Eleven chips that state a grade beside a reading (confidence, score gain, + * band, lab range) were hand-rolled in two looks: a /15 wash without border + * in the crosstabs, a /10 wash with a /40 border on the correlation and trend + * cards, and the middle confidence step was blue on one and amber on the + * other. This guard keeps a new copy of the chip from appearing beside the + * primitive. + * + * Matched: one string literal that combines the pill shape (`rounded-full`, + * a small padding `px-1.5|2|2.5`, `text-xs|2xs`) with a semantic tone wash + * (`bg-success|info|warning|destructive/10|15`). A neutral `bg-muted` chip is + * `TagChip`'s business, not this guard's. + * + * Not matched: a tone assembled at runtime from a map whose entries live in + * another literal. The guard catches the idiom, not every way to paint one. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); + +const ALLOWED: Record = { + // A link to the release notes styled as a call to action, not a grade. + "components/admin/version-tile-section.tsx": + "update-available link, not a grade", +}; + +const LITERAL_RE = /["'`]([^"'`\n]*\brounded-full\b[^"'`\n]*)["'`]/g; + +function isGradeChip(cls: string): boolean { + return ( + /(?:^|\s)px-(?:1\.5|2|2\.5)(?:\s|$)/.test(cls) && + /(?:^|\s)text-(?:xs|2xs)(?:\s|$)/.test(cls) && + /(?:^|\s)bg-(?:success|info|warning|destructive)\/1[05](?:\s|$)/.test(cls) + ); +} + +function findGradeChips(files: { path: string; source: string }[]): string[] { + const hits = new Set(); + for (const { path, source } of files) { + for (const match of source.matchAll(LITERAL_RE)) { + if (isGradeChip(match[1])) hits.add(path); + } + } + return [...hits].sort(); +} + +describe("grade chips go through ScorePill", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter( + (p) => + (p.startsWith("components/") || p.startsWith("app/")) && + !p.startsWith("components/ui/") && + !p.includes("__tests__/"), + ) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the UI tree", () => { + expect(files.length).toBeGreaterThan(1000); + }); + + it("has no hand-rolled grade chip outside the allowlist", () => { + expect(findGradeChips(files).filter((p) => !(p in ALLOWED))).toEqual([]); + }); + + it("keeps every allowlist entry live", () => { + const hits = new Set(findGradeChips(files)); + for (const key of Object.keys(ALLOWED)) + expect(hits.has(key), key).toBe(true); + }); + + it("recognises the idiom it exists to refuse", () => { + expect( + findGradeChips([ + { + path: "components/a.tsx", + source: + 'const C = { high: "bg-success/15 text-success" }; ', + }, + { + path: "components/b.tsx", + source: + '', + }, + ]), + ).toEqual(["components/a.tsx"]); + }); + + it("is used where the copies were", () => { + const users = files.filter((f) => f.source.includes(" = { Activity, @@ -351,7 +352,7 @@ export default function AchievementsPage() { />
-
+

{t("achievements.points")}

@@ -370,8 +371,8 @@ export default function AchievementsPage() { className="h-1.5" aria-label={t("achievements.points")} /> -
-
+ +

{t("achievements.unlocked")}

@@ -384,8 +385,8 @@ export default function AchievementsPage() {

{t("achievements.remainingUnlocks", { count: remainingUnlocks })}

-
-
+ +

{t("achievements.nextGoal")}

@@ -428,7 +429,7 @@ export default function AchievementsPage() { className="mt-3 h-1.5" aria-label={t("achievements.nextProgressLabel")} /> -
+
{grouped.length === 0 ? ( diff --git a/src/app/coach/page-client.tsx b/src/app/coach/page-client.tsx index 0e1d404b1..8c08143a6 100644 --- a/src/app/coach/page-client.tsx +++ b/src/app/coach/page-client.tsx @@ -14,7 +14,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; -import { coachScopeSourceSchema } from "@/lib/ai/coach/types"; +import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; /** * v1.12.0 (Coach v2 #6) — full-page Coach conversation. @@ -117,12 +118,13 @@ function CoachPageBody() { // chat (`?doc=`) keeps its own established scope. const freshChat = deepLinkedId === null && seedDocumentId === null; const rawScope = freshChat ? searchParams.get("scope") : null; - const scopeResult = rawScope - ? coachScopeSourceSchema.safeParse(rawScope) - : null; - const launchScope: CoachLaunchScope | null = scopeResult?.success - ? { metric: scopeResult.data } - : null; + // A closed-set check, not a schema parse: the route stays free of the + // schema library for one string comparison. + const launchScope: CoachLaunchScope | null = + rawScope !== null && + (COACH_SCOPE_SOURCES as readonly string[]).includes(rawScope) + ? { metric: rawScope as CoachScopeSource } + : null; const seedPrefill = freshChat ? searchParams.get("ask") : null; // Read once: the panel consumes it on mount, then the URL loses it. diff --git a/src/app/globals.css b/src/app/globals.css index 8006ffce5..515f569a7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -232,7 +232,7 @@ /* * v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment` * above (an orphaned token — no `@theme` utility was ever generated for - * it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never + * it — and its `#9aa3b3` value never * matched the classic Dracula "Comment" blue-grey this chart actually * paints). Minted here instead of retuning the unrelated token, so this * default carries the exact hex the stacked bar hardcoded before diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index 49f601778..e71f3cabc 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -181,13 +181,11 @@ describe("assessment presentation contract", () => { "utf8", ); - expect(cardSource).toContain( - 'className="animate-insight-in gap-2 py-3 md:py-4"', - ); + expect(cardSource).toContain('density="compact"'); + expect(cardSource).toContain('className="animate-insight-in"'); expect(cardSource).toContain('className="text-foreground text-sm"'); - expect(moodSource).toContain( - 'className="animate-insight-in gap-2 py-3 md:py-4"', - ); + expect(moodSource).toContain('density="compact"'); + expect(moodSource).toContain('className="animate-insight-in"'); expect(moodSource).toContain('data-slot="insight-assessment"'); expect(moodBodySource).toContain( 'className="text-foreground mb-2 text-sm"', diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index a6772ce9c..43186b0a4 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -204,7 +204,7 @@ export default function InsightsBlutdruckPage() { // edge, ~16–24 px left of every neighbouring card's text) — and // foreground, not muted: multi-sentence explainer prose is // content, muted stays reserved for meta. - +

{t("insights.subPage.bpDerived.pulsePressure", { diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 88425d5f7..805a7bdfe 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -351,11 +351,7 @@ export default function MetricCatalogPage() { // it ever probes for rows, so a module-off account never saw // the in-context enable CTA; this row is the fix). return ( - + @@ -18,21 +19,25 @@ export default function InsightsLoading() { {/* Chart card */} -

- - -
+ + + + + + {/* Assessment card */} -
-
- - -
- - - -
+ + +
+ + +
+ + + +
+
); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 08fccaf84..d017986c9 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -248,7 +248,7 @@ export default function InsightsMedikamentePage() { med.categoryLabel, ); return ( - +
@@ -394,11 +394,7 @@ function MedicationComplianceCalendar({ } if (!data?.dailyCompliance) { - return ( -
- {t("insights.complianceNoData")} -
- ); + return ; } return ( diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 81c888314..f99f89681 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ArrowRight, Gauge, Heart } from "lucide-react"; +import { Gauge, Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -19,9 +19,9 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TileHeader } from "@/components/insights/tile-header"; import dynamic from "next/dynamic"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { CrossLinkCard } from "@/components/insights/cross-link-card"; // Load the intraday area chart through the shared chart-runtime boundary (the // one recharts chunk), not as a direct import — a direct `from "recharts"` @@ -199,27 +199,17 @@ export default function InsightsPulsPage() { the full chart plus the plain-language assessment — rather than duplicating a second, divergent VO₂ max view here. - v1.12.8 — the card now leads with the canonical `` so the - `Gauge` glyph reads in the foreground colour at the same size and - position as every other tile header, with the body + CTA beneath. */} + The card is the shared `CrossLinkCard`: the canonical `TileHeader` + (foreground `Gauge` glyph), the body and the CTA beneath. */} {hasVo2 ? ( - - - - {t("insights.vo2Max.cardioLinkBody")} - - - {t("insights.vo2Max.cardioLinkCta")} - - + slot="vo2-cardio-link" + icon={Gauge} + title={t("insights.vo2Max.cardioLinkTitle")} + body={t("insights.vo2Max.cardioLinkBody")} + cta={t("insights.vo2Max.cardioLinkCta")} + /> ) : null} ); diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index 0cf9cdd60..c307d65af 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -20,6 +20,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { canLogWorkout } from "@/lib/workouts/manual-entry"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { translateOrNull } from "@/lib/i18n/translate-code"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.4.32 — `/insights/workouts`. @@ -187,12 +188,13 @@ export default function InsightsWorkoutsPageClient() { /> ) : ( -

- {t("insights.workouts.filterEmpty")} -

+ /> )} )} diff --git a/src/app/mood/page-client.tsx b/src/app/mood/page-client.tsx index c2a24f008..a5cf60065 100644 --- a/src/app/mood/page-client.tsx +++ b/src/app/mood/page-client.tsx @@ -18,6 +18,7 @@ import { ArrowRight, Plus, Wrench } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; +import { InlineAction } from "@/components/ui/inline-action"; export default function MoodPageClient() { const { user, isAuthenticated, isLoading } = useAuth(); @@ -197,14 +198,12 @@ export default function MoodPageClient() { here (module-gated — no nav change) rather than a merge: capture vs. screener stay two defensible surfaces, just cross-linked. */} {user?.modules?.mentalHealth === true ? ( - - {t("mood.mentalWellbeingLink")} -
); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 7ce11570b..4c14bd9e3 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -22,6 +22,13 @@ import { } from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; interface ChannelInfo { id: string; @@ -309,17 +316,16 @@ export default function NotificationsPage() { const nameKey = eventTranslationKey(eventType); const descKey = eventDescriptionTranslationKey(eventType); return ( -
-
-
{t(nameKey)}
-
+ + + + {t(nameKey)} + + {t(descKey)} -
-
-
+ + + {activeChannels.map((ch) => { const enabled = isEnabled(ch.id, eventType); const disabled = !ch.enabled; @@ -355,8 +361,8 @@ export default function NotificationsPage() {
); })} -
-
+ + ); })}
diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index a4bc7ee67..97ee96201 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { helpfulRateColour } from "./_shared"; +import { EmptyState } from "@/components/ui/empty-state"; interface FeedbackBucket { severity: string; @@ -110,9 +111,11 @@ export function AiQualitySection() { onRetry={() => void query.refetch()} /> ) : !hasData ? ( -

- {t("admin.aiQuality.empty")} -

+ ) : (
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 16b6e5ed4..2f3ab6759 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -843,7 +843,7 @@ async function uploadOnce(file: File) { } export function BackupsSection() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -1327,7 +1327,7 @@ export function BackupsSection() {
- {formatBytes(row.sizeBytes, fmt)} + {formatBytes(row.sizeBytes, locale)} {fmt.dateTime(row.createdAt)} @@ -1394,7 +1394,7 @@ export function BackupsSection() {

{t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} + {formatBytes(row.sizeBytes, locale)} ·{" "} {fmt.dateTime(row.createdAt)}

diff --git a/src/components/admin/central-codex-section.tsx b/src/components/admin/central-codex-section.tsx index 7ae0ffb97..c9d9e1734 100644 --- a/src/components/admin/central-codex-section.tsx +++ b/src/components/admin/central-codex-section.tsx @@ -36,6 +36,7 @@ import { invalidateKeys, queryKeys, } from "@/lib/query-keys"; +import { InlineAction } from "@/components/ui/inline-action"; interface CentralCodexStatus { status: string; @@ -228,15 +229,15 @@ export function CentralCodexSection() { {t("settings.ai.deviceCodeStep2")}
{deviceCode.userCode} - +
  • {t("settings.ai.deviceCodeStep3")}
  • diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index d62a4f8ba..09ac117bc 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -28,6 +28,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { helpfulRateColour } from "./_shared"; +import { EmptyState } from "@/components/ui/empty-state"; interface CoachFeedbackBucket { promptVersion: string; @@ -105,9 +106,11 @@ export function CoachFeedbackSection() { onRetry={() => void query.refetch()} /> ) : !hasData ? ( -

    - {t("admin.coachFeedback.empty")} -

    + ) : (
    diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 78230e0cf..4ba20e22b 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -53,6 +53,7 @@ import { import { KeyBackupCard } from "./key-backup-card"; import { useKeyBackupStatus } from "./use-key-backup-status"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; interface ColumnScan { model: string; @@ -229,11 +230,11 @@ export function EncryptionSection() { /> {/* "Safe to drop the legacy key" needs a legacy key to drop. With a @@ -414,6 +415,7 @@ export function ColumnCoverage({ initiallyOpen?: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [open, setOpen] = useState(initiallyOpen); const [showAll, setShowAll] = useState(false); const withRows = columns.filter((c) => c.total > 0); @@ -445,9 +447,11 @@ export function ColumnCoverage({ {open ? ( <> {shown.length === 0 ? ( -

    - {t("admin.section.encryption.columnsNoneWithRows")} -

    + ) : ( /* Focusable, named scroll region: on a phone the table scrolls sideways, and a keyboard user has to be able to reach it @@ -484,16 +488,12 @@ export function ColumnCoverage({
    - - + +
    {c.model}.{c.field} - {c.total.toLocaleString()} - - {active.toLocaleString()} - {fmt.integer(c.total)}{fmt.integer(active)} {stale > 0 ? ( - {stale.toLocaleString()} + {fmt.integer(stale)} ) : ( 0 diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 4182d6e3b..d98b9f0e1 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -54,7 +54,8 @@ export function GeneralSettingsSection() { : ""; const storedQuotaGb = settings?.documentQuotaBytes != null - ? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) + ? // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop + String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10) : ""; const commitMaxFile = () => { diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 20987aa64..702b779fe 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -47,6 +47,8 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiGet } from "@/lib/api/api-fetch"; import { readStoredTimezone } from "@/lib/timezone-mirror"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { EmptyState } from "@/components/ui/empty-state"; interface HostMetricsApiSample { capturedAt: string; @@ -89,7 +91,10 @@ const COLOR_DISK = "var(--dracula-purple)"; * + the absolute peak BPS used to scale `diskBusyPercent`. Kept as a * separate export so the test suite doesn't need to render Recharts. */ -export function buildChartRows(samples: HostMetricsApiSample[]): { +export function buildChartRows( + samples: HostMetricsApiSample[], + intlLocale?: string, +): { rows: ChartRow[]; peakDiskBps: number; hasDiskData: boolean; @@ -104,7 +109,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { // samples), and an operator reading their own instance should see the same // clock here as on every other timestamp in the admin area — a spike at // "14:20" must not be 14:20 here and 15:20 two screens away. - const formatter = new Intl.DateTimeFormat(undefined, { + const formatter = new Intl.DateTimeFormat(intlLocale ?? "en-US", { hour: "2-digit", minute: "2-digit", hour12: false, @@ -141,7 +146,7 @@ export function buildChartRows(samples: HostMetricsApiSample[]): { } export function HostMetricsChart() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const { data, isLoading, isError, error, refetch } = useQuery({ @@ -156,8 +161,8 @@ export function HostMetricsChart() { }); const { rows, peakDiskBps, hasDiskData } = useMemo( - () => buildChartRows(data?.samples ?? []), - [data?.samples], + () => buildChartRows(data?.samples ?? [], resolveIntlLocale(locale)), + [data?.samples, locale], ); // Total physical memory (latest sample) — lets the tooltip pair the @@ -223,9 +228,11 @@ export function HostMetricsChart() { } /> -

    - {t("admin.hostMetrics.empty")} -

    + ); } diff --git a/src/components/admin/measurement-maintenance-card.tsx b/src/components/admin/measurement-maintenance-card.tsx index 6109f5a89..3569c0e64 100644 --- a/src/components/admin/measurement-maintenance-card.tsx +++ b/src/components/admin/measurement-maintenance-card.tsx @@ -45,20 +45,8 @@ const ENDPOINT = "/api/admin/maintenance/measurements"; /** How often the card asks while a run is waiting or working. */ const POLL_MS = 10_000; -const GIB = 1024 * 1024 * 1024; - -function sizeLabel( - bytes: number, - fmt: ReturnType, -): string { - // The shared formatter stops at MB; this table is routinely gigabytes. - return bytes >= GIB - ? `${fmt.number(bytes / GIB, 2)} GB` - : formatBytes(bytes, fmt); -} - export function MeasurementMaintenanceCard() { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -132,11 +120,11 @@ export function MeasurementMaintenanceCard() {
    ) : null} diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index df5442811..f57122c64 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -23,6 +23,7 @@ import { import type { ModuleKey } from "@/lib/modules/registry"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; /** * v1.18.0 — operator-side panel for server-wide module availability, the @@ -127,12 +128,11 @@ export function ModuleAvailabilitySection() { onCheckedChange={() => {}} disabled /> - - {t("admin.section.coach.title")} - + + + {t("admin.section.coach.title")} + + ); } diff --git a/src/components/admin/offhost-backups-section.tsx b/src/components/admin/offhost-backups-section.tsx index 99ffb6af2..3e65f6402 100644 --- a/src/components/admin/offhost-backups-section.tsx +++ b/src/components/admin/offhost-backups-section.tsx @@ -84,7 +84,7 @@ function freshnessLabel( } function AccountRow({ row }: { row: OffhostAccountRow }) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); return ( @@ -128,7 +128,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) { }) : `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes( row.sizeBytes, - fmt, + locale, )} · ${t("admin.section.backups.offhost.age", { hours: row.ageHours ?? 0, })}`} diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 3032ee8ef..4df470bea 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { EmptyState } from "@/components/ui/empty-state"; /** * v1.37.31 — the delivery-health readout the `provider_health` ledger never @@ -143,9 +144,11 @@ export function ProviderHealthSection() { onRetry={() => void refetch()} /> ) : providers.length === 0 ? ( -

    - {t("admin.providerHealth.empty")} -

    + ) : (
      {providers.map((p) => ( diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index cab8589b7..0fb5ac6e4 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -27,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; import { apiGet } from "@/lib/api/api-fetch"; +import { InlineAction } from "@/components/ui/inline-action"; const PREVIEW_LIMIT = 10; @@ -61,13 +62,12 @@ export function RecentAuditPreview() { titleId="admin-overview-audit-heading" title={t("admin.overview.auditTitle")} status={ - - {t("admin.overview.auditViewAll")} -
    ) : ( -

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

    + )} @@ -88,9 +91,11 @@ export function ProfileSummary({ ))} ) : ( -

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

    + )} @@ -112,9 +117,11 @@ export function ProfileSummary({ ))} ) : ( -

    - {t("records.profileFacts.notRecorded")} -

    + )} diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx deleted file mode 100644 index d1663c12d..000000000 --- a/src/components/settings/__tests__/integration-status-pill.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -/** - * v1.4.19 — IntegrationStatusPill - * - * The single tag rendered top-right of every integration card. The - * pill is the ONLY place a connection status surfaces — the maintainer was - * staring at three- to four-fold redundancy across Withings + Mood Log - * cards in v1.4.18 and called it out. This component is reusable so - * v1.4.20 can drop the same pill on the Apple Health card. - * - * The four states this file locks in (one assertion each): - * 1. connected → "Connected · 12 min ago" pattern - * 2. error → "Error — reconnect" pattern - * 3. error (without ts) → bare error label, no "ago" suffix - * 4. disconnected → "Not connected" pattern (no relative ts) - * - * Plus locale parity: switching to `de` renders the German strings. - * Plus a "no last-sync timestamp" guard for connected state. - */ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { IntegrationStatusPill } from "../integration-status-pill"; -import { primeLocales } from "@/__tests__/helpers/prime-locales"; - -primeLocales("de"); - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("IntegrationStatusPill", () => { - it("renders the connected state with relative time when lastSyncAt is recent", () => { - const twelveMinAgo = new Date(Date.now() - 12 * 60 * 1000); - const html = render( - , - ); - expect(html).toContain("Connected"); - // 12 min in EN locale - expect(html).toMatch(/12\s+min(\.|utes)?\s+ago/); - // Renders within a single pill marker so cards can target it. - expect(html).toContain('data-testid="integration-status-pill"'); - expect(html).toContain('data-state="connected"'); - }); - - it("renders the connected state without relative time when lastSyncAt is null", () => { - const html = render( - , - ); - expect(html).toContain("Connected"); - // Must not render the relative-time separator " · " when the - // timestamp is missing, otherwise the pill reads "Connected · ". - expect(html).not.toMatch(/Connected.*\s·\s\s/); - }); - - it("renders the error state with the reconnect-cta phrasing", () => { - const html = render( - , - ); - expect(html).toContain("Error"); - expect(html).toContain("reconnect"); - expect(html).toContain('data-state="error"'); - }); - - it("renders the disconnected state with the 'not connected' label", () => { - const html = render( - , - ); - expect(html).toContain("Not connected"); - expect(html).toContain('data-state="disconnected"'); - }); - - it("renders German strings when the active locale is 'de'", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Nicht verbunden"); - }); - - it("uses an abbreviated relative-time form for very recent syncs (< 1 min)", () => { - const fortySecondsAgo = new Date(Date.now() - 40 * 1000); - const html = render( - , - ); - // Sub-minute syncs collapse to "just now" so the pill stays - // narrow on Pixel 5. - expect(html).toMatch(/just\s+now/i); - }); - - // v1.4.43 — parked-state copy. - // Distinct from `error` (red reconnect pill) because the user can - // resume the integration without redoing the OAuth dance; distinct - // from `warning` because the persistent streak survived the alert - // ladder AND the 24h grace window — manual intervention required. - it("renders the parked state with manual-reconnect phrasing (EN)", () => { - const html = render( - , - ); - expect(html).toContain("Paused"); - expect(html).toContain("Paused: reconnect"); - expect(html).toContain('data-state="parked"'); - }); - - it("renders the parked state with manual-reconnect phrasing (DE)", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Pausiert"); - expect(html).toContain("Pausiert: neu verbinden"); - }); - - // The three states the server verdict added. `stale` and `stalled` both - // carry an inline timestamp, because "since when" is the whole point of - // them; `pending-setup` has nothing to timestamp yet. - it("renders the stale state in the warning tone with its timestamp", () => { - const html = render( - , - ); - expect(html).toContain('data-state="stale"'); - expect(html).toContain("no recent data"); - expect(html).toMatch(/9\s+d\s+ago/); - expect(html).toContain("bg-warning/15"); - }); - - it("renders the stalled state as a stopped sync, not an error", () => { - const html = render( - , - ); - expect(html).toContain('data-state="stalled"'); - expect(html).toContain("Sync stopped"); - expect(html).toMatch(/28\s+d\s+ago/); - // Warning tone, never destructive: the user cannot fix a cron that - // stopped by clicking reconnect. - expect(html).toContain("bg-warning/15"); - expect(html).not.toContain('data-variant="destructive"'); - }); - - it("renders the pending-setup state with no timestamp", () => { - const html = render( - , - ); - expect(html).toContain('data-state="pending-setup"'); - expect(html).toContain("Waiting for first data"); - expect(html).not.toContain("bg-warning/15"); - }); - - /** - * Red is reserved for "your action fixes this". A transient upstream failure - * is not that: telling the user to reconnect against a 503 sends them - * clicking at something they cannot repair. - */ - it("keeps the destructive variant for reauth alone", () => { - expect( - render(), - ).toContain('data-variant="destructive"'); - for (const state of [ - "warning", - "stale", - "stalled", - "parked", - "pending-setup", - "connected", - ] as const) { - expect( - render(), - state, - ).not.toContain('data-variant="destructive"'); - } - }); - - it("lets a card keep its own established testid", () => { - const html = render( - , - ); - expect(html).toContain('data-testid="apple-health-status"'); - }); -}); - -describe("IntegrationStatusPill — a failing provider states its age", () => { - it("renders the failing state with how long it has been failing", () => { - const seventeenDaysAgo = new Date(Date.now() - 17 * 24 * 60 * 60 * 1000); - const html = render( - , - ); - // The label must not open with a claim of connection: the pipe has - // delivered nothing for a fortnight. - expect(html).not.toContain("Connected"); - expect(html).toContain("Sync failing"); - // And the age has to be on the pill. Without it, hour one and day - // seventeen render identically, which is how a dead sync stayed quiet. - expect(html).toMatch(/17\s*d\s+ago/); - expect(html).toContain('data-state="warning"'); - }); - - it("renders the localized failure count against the server threshold", () => { - const html = render( - , - ); - - expect(html).toContain("2/3"); - }); - - it("omits a partial failure ratio", () => { - const html = render( - , - ); - - expect(html).not.toContain("2/"); - }); - - it("keeps the German label free of a connection claim too", () => { - const html = render( - , - "de", - ); - expect(html).not.toContain("Verbunden"); - expect(html).toContain("Sync fehlgeschlagen"); - }); -}); - -describe("IntegrationStatusPill — accessible name", () => { - /** - * A generic `aria-label` used to sit on the badge. Being a label, it - * REPLACED the accessible name with itself, so a screen reader announced the - * same words for a healthy connection as for a broken one — erasing the one - * distinction the pill exists to draw. - */ - it.each([ - ["connected", "Connected"], - ["error", "Error"], - ["parked", "Paused"], - ] as const)("lets the %s label be the accessible name", (state, label) => { - const html = render( - , - ); - expect(html).not.toContain("aria-label="); - expect(html).toContain(label); - }); -}); diff --git a/src/components/settings/_card-header.tsx b/src/components/settings/_card-header.tsx index 16f53a465..d0382bd69 100644 --- a/src/components/settings/_card-header.tsx +++ b/src/components/settings/_card-header.tsx @@ -61,8 +61,7 @@ export interface SettingsCardHeaderProps { * anchors it relies on are pinned in `src/lib/command-palette/ * settings-cards.ts`. */ anchor?: string; - /** Optional right-aligned status surface — typically an - * `` or a wrapper around badges. */ + /** Optional right-aligned status surface — typically a badge. */ status?: React.ReactNode; /** Optional extra classes applied to the outer wrapper. */ className?: string; diff --git a/src/components/settings/access/handover-decision.tsx b/src/components/settings/access/handover-decision.tsx index 0dc356357..a3f619edb 100644 --- a/src/components/settings/access/handover-decision.tsx +++ b/src/components/settings/access/handover-decision.tsx @@ -22,6 +22,7 @@ import { useHandoverDecision, type HandoverDecisionState, } from "@/lib/queries/use-handover-decision"; +import { InlineAction } from "@/components/ui/inline-action"; type PendingDecision = NonNullable; @@ -191,13 +192,14 @@ export function HandoverNotificationHint() {

    {t("recordSharing.handoverDecision.notificationsBody")}

    - - {t("recordSharing.handoverDecision.notificationsAction")} - + + + {t("recordSharing.handoverDecision.notificationsAction")} + + ); diff --git a/src/components/settings/ai/ai-usage-card.tsx b/src/components/settings/ai/ai-usage-card.tsx index 6e1bd32fd..b3002b48b 100644 --- a/src/components/settings/ai/ai-usage-card.tsx +++ b/src/components/settings/ai/ai-usage-card.tsx @@ -20,7 +20,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Gauge } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { nextChoiceIndex } from "@/components/documents/sources/segmented-choice"; +import { rovingRadioNextIndex } from "@/hooks/use-roving-radio-group"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -94,7 +94,7 @@ export function AiUsageCard() { level: AiUsageLevel, ) { const from = allowedLevels.indexOf(level); - const next = nextChoiceIndex(event.key, from, allowedLevels.length); + const next = rovingRadioNextIndex(event.key, from, allowedLevels.length); if (next === null) return; event.preventDefault(); const target = allowedLevels[next]!; diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index eaf4098d2..78bfce68f 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { InsightsSettings } from "./shared"; +import { InlineAction } from "@/components/ui/inline-action"; export function CodexProviderForm({ settings, @@ -241,15 +242,15 @@ export function CodexProviderForm({ {t("settings.ai.deviceCodeStep2")}
    {deviceCode.userCode} - +
  • {t("settings.ai.deviceCodeStep3")}
  • diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index b598f9514..1d0f077e0 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -27,6 +27,7 @@ import { uiToLegacyProviderEnum, type UserAIProvider, } from "./shared"; +import { InlineAction } from "@/components/ui/inline-action"; export function OpenAIProviderForm({ userProvider, @@ -172,15 +173,15 @@ export function OpenAIProviderForm({ )}
    - + {showAdvanced && (
    diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 0c3b85f6a..17e62753f 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -46,6 +46,7 @@ import { type ExistingFactorProof, type ReauthMethod, } from "./existing-factor-reauth-dialog"; +import { InlineAction } from "@/components/ui/inline-action"; interface SetupData { otpauthUri: string; @@ -430,13 +431,9 @@ export function TotpCard({ {t("settings.security.totp.verify")} - + )} @@ -564,9 +561,8 @@ export function TotpCard({ } className="font-mono" /> - + {t("common.cancel")} diff --git a/src/components/settings/share-document-picker.tsx b/src/components/settings/share-document-picker.tsx index a00495378..65dfc1f05 100644 --- a/src/components/settings/share-document-picker.tsx +++ b/src/components/settings/share-document-picker.tsx @@ -9,25 +9,12 @@ * is bounded at `max` (mirrors `SHARE_LINK_MAX_DOCUMENTS`): once the cap is * reached, unselected rows disable so the client never posts an oversized set. * - * Reuses the vault list endpoint + the `inboundDocumentPicker` query key from - * the central factory; reads unwrap `(await res.json()).data` via `apiGet`. - * No write happens here — attaching is the create mutation's job. + * The list, search and rows are the shared `DocumentPickerSheet`. No write + * happens here — attaching is the create mutation's job. */ -import { useDocumentDate } from "@/components/documents/use-document-date"; -import { useQuery } from "@tanstack/react-query"; -import { Check, Search } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - +import { DocumentPickerSheet } from "@/components/documents/document-picker-sheet"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; -import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; /** A picked document, carried by id + a display title (for the owner chips). */ @@ -36,11 +23,6 @@ export interface PickedDocument { title: string; } -interface ListPage { - documents: InboundDocumentDto[]; - nextCursor: string | null; -} - export function ShareDocumentPicker({ open, onOpenChange, @@ -55,38 +37,6 @@ export function ShareDocumentPicker({ max: number; }) { const { t } = useTranslations(); - const documentDate = useDocumentDate(); - - const [searchDraft, setSearchDraft] = useState(""); - const [query, setQuery] = useState(""); - - // 200 ms debounce, matching the vault's search feel. - const debounceRef = useRef | null>(null); - useEffect( - () => () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }, - [], - ); - const onSearchChange = (value: string) => { - setSearchDraft(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setQuery(value.trim()), 200); - }; - - const list = useQuery({ - queryKey: queryKeys.inboundDocumentPicker(query), - enabled: open, - queryFn: () => { - const sp = new URLSearchParams(); - if (query) sp.set("q", query); - sp.set("sort", "documentDate"); - sp.set("order", "desc"); - sp.set("limit", "50"); - return apiGet(`/api/documents/inbound?${sp.toString()}`); - }, - }); - const selectedIds = new Set(selected.map((s) => s.id)); const atCap = selected.length >= max; @@ -99,14 +49,28 @@ export function ShareDocumentPicker({ onSelectedChange([...selected, { id: doc.id, title }]); } - const documents = list.data?.documents ?? []; - return ( - selectedIds.has(doc.id)} + isDisabled={(_doc, picked) => !picked && atCap} + onToggle={(doc, title) => toggle(doc, title)} + notice={ + atCap ? ( +

    + {t("settings.sharing.pickerMaxReached", { max })} +

    + ) : null + } footer={
    @@ -120,99 +84,6 @@ export function ShareDocumentPicker({
    } - > -
    -
    - - onSearchChange(e.target.value)} - placeholder={t("settings.sharing.pickerSearch")} - aria-label={t("settings.sharing.pickerSearch")} - className="pl-9" - /> -
    - - {atCap ? ( -

    - {t("settings.sharing.pickerMaxReached", { max })} -

    - ) : null} - - {list.isPending && open ? ( -
    - {Array.from({ length: 4 }, (_, i) => ( - - ))} -
    - ) : list.isError ? ( - list.refetch()} - /> - ) : documents.length === 0 ? ( -

    - {t("settings.sharing.pickerEmpty")} -

    - ) : ( -
      - {documents.map((doc) => { - const picked = selectedIds.has(doc.id); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; - const disabled = !picked && atCap; - return ( -
    • - -
    • - ); - })} -
    - )} -
    -
    + /> ); } diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx index 90e6cd695..583e26877 100644 --- a/src/components/settings/share-link-create-form.tsx +++ b/src/components/settings/share-link-create-form.tsx @@ -36,7 +36,7 @@ import { import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker"; import { ScopeSummary } from "@/components/settings/report-selection/scope-summary"; import type { ReportLeafId } from "@/lib/report-selection/catalogue"; -import { orderLeaves } from "@/lib/report-selection/selection"; +import { orderLeaves } from "@/lib/report-selection/catalogue"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; diff --git a/src/components/symptoms/symptoms-section.tsx b/src/components/symptoms/symptoms-section.tsx index 4c64ee22a..27c08b78c 100644 --- a/src/components/symptoms/symptoms-section.tsx +++ b/src/components/symptoms/symptoms-section.tsx @@ -68,6 +68,7 @@ import { useSymptomEvents, useUpdateSymptomDefinition, } from "./use-symptoms"; +import { EmptyState } from "@/components/ui/empty-state"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -489,9 +490,11 @@ export function SymptomsSection() { {t("symptoms.section.recent")}

    {days.length === 0 ? ( -

    - {t("symptoms.section.recentEmpty")} -

    + ) : (
    {days.map((group) => ( diff --git a/src/components/targets/target-status-pill.tsx b/src/components/targets/target-status-pill.tsx index 394d5be78..7446f085d 100644 --- a/src/components/targets/target-status-pill.tsx +++ b/src/components/targets/target-status-pill.tsx @@ -6,8 +6,8 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { ScorePill } from "@/components/ui/score-pill"; /** * v1.8.5 — `` extracted from `target-card.tsx`. @@ -153,7 +153,8 @@ export function statusGroupForCategory( return "near"; } -// Coloured text on a 10% same-hue tint with a 30% ring. The tint and +// Coloured text on a 10% same-hue tint, in the `ScorePill` anatomy. The +// tone classes stay this pill's own, on purpose: the tint and // text share one theme-aware token per tone (`--status-pill-{in,near, // out}` in globals.css) so the pill clears WCAG AA (4.5:1) on both the // Targets card (`bg-card`) and the W5 insights reference panel @@ -171,9 +172,9 @@ export function statusGroupForCategory( // that parses cleanly. See the Tailwind v4 upgrade guide for the // migration from bracket-form to paren-form for CSS-variable values. export const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = { - in: "bg-(--status-pill-in)/10 text-(--status-pill-in) ring-(--status-pill-in)/30", - near: "bg-(--status-pill-near)/10 text-(--status-pill-near) ring-(--status-pill-near)/30", - out: "bg-(--status-pill-out)/10 text-(--status-pill-out) ring-(--status-pill-out)/30", + in: "bg-(--status-pill-in)/10 text-(--status-pill-in)", + near: "bg-(--status-pill-near)/10 text-(--status-pill-near)", + out: "bg-(--status-pill-out)/10 text-(--status-pill-out)", }; export interface TargetStatusPillProps { @@ -204,16 +205,13 @@ export function TargetStatusPill({ - {statusLabel} - + {range && ( diff --git a/src/components/timeline/life-event-form.tsx b/src/components/timeline/life-event-form.tsx index e329b5930..90603215e 100644 --- a/src/components/timeline/life-event-form.tsx +++ b/src/components/timeline/life-event-form.tsx @@ -52,7 +52,7 @@ import { LIFE_EVENT_ERROR_KEY, LIFE_EVENT_PRECISION_KEY, } from "./label-keys"; -import { Segmented } from "./segmented"; +import { Segmented } from "@/components/ui/segmented"; import { formatMonthLong, todayKeyIn } from "./timeline-dates"; import { useLifeEventMutations } from "./use-timeline"; @@ -393,7 +393,7 @@ export function LifeEventForm({ label={t("lifeEvents.fields.precision")} slot="life-event-precision" stretch - asToggleButtons + mode="toggle" />
    diff --git a/src/components/timeline/range-picker.tsx b/src/components/timeline/range-picker.tsx index 69063c358..1a33c4f33 100644 --- a/src/components/timeline/range-picker.tsx +++ b/src/components/timeline/range-picker.tsx @@ -22,7 +22,7 @@ import type { TimelineZoom } from "@/lib/day/contract"; import { useTranslations } from "@/lib/i18n/context"; import { RangeFields } from "./range-fields"; -import { Segmented, type SegmentedOption } from "./segmented"; +import { Segmented, type SegmentedOption } from "@/components/ui/segmented"; import type { TimelineRange } from "./timeline-url"; interface RangeLimits { diff --git a/src/components/timeline/readiness-card.tsx b/src/components/timeline/readiness-card.tsx index 447312685..566285005 100644 --- a/src/components/timeline/readiness-card.tsx +++ b/src/components/timeline/readiness-card.tsx @@ -41,7 +41,7 @@ export function ReadinessCard({ : t("timeline.readiness.meter", { carrying, total }); return ( - + { - value: T; - label: string; -} - -export function Segmented({ - options, - value, - onChange, - label, - slot, - className, - stretch = false, - asToggleButtons = false, - wrapOption, -}: { - options: ReadonlyArray>; - value: T; - onChange: (next: T) => void; - label: string; - slot: string; - className?: string; - stretch?: boolean; - asToggleButtons?: boolean; - /** - * Wraps one segment's button, for a popover anchored to it (the range - * segment). Returns the button itself for every other segment. - */ - wrapOption?: (value: T, button: ReactElement) => ReactNode; -}) { - const selectedIndex = options.findIndex((o) => o.value === value); - const { getRadioProps } = useRovingRadioGroup({ - count: options.length, - selectedIndex, - onSelect: (index) => onChange(options[index].value), - }); - return ( -
    - {options.map((option, index) => { - const checked = option.value === value; - const button = ( - - ); - return wrapOption ? ( - - {wrapOption(option.value, button)} - - ) : ( - button - ); - })} -
    - ); -} diff --git a/src/components/timeline/timeline-chronicle.tsx b/src/components/timeline/timeline-chronicle.tsx index 9974295fc..f8c1ee212 100644 --- a/src/components/timeline/timeline-chronicle.tsx +++ b/src/components/timeline/timeline-chronicle.tsx @@ -222,7 +222,7 @@ export function TimelineChronicle({ return (
    {chips.length > 0 && ( - + + ); + return wrapOption ? ( + + {wrapOption(option.value, button)} + + ) : ( + button + ); + })} +
    + ); +} diff --git a/src/components/ui/trend-tile.tsx b/src/components/ui/trend-tile.tsx new file mode 100644 index 000000000..242465951 --- /dev/null +++ b/src/components/ui/trend-tile.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +/** + * The frame of a metric trend tile: the dashboard KPI strip (`TrendCard`), + * the derived-insight tiles (`SparklineDeltaTile`), their skeletons and the + * "still building" states of the vitals board. + * + * Seven copies spelled this frame out by hand, and they had started to + * differ (some clipped their content, some did not). The frame is a dense + * tile of its own rather than a `Card`: it sits in a grid of equal-height + * tiles (`h-full`), insets on the card padding (`p-4 md:p-6`, UI standards + * §1), carries no shadow, and clips a value that would otherwise push the + * grid cell wider (`min-w-0 overflow-hidden`). + * + * The vertical rhythm inside belongs to the tile: pass `gap-3` for a stacked + * state, or let the children set their own `mt-*` steps as the loaded tiles do. + */ +function TrendTile({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +export { TrendTile }; diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx deleted file mode 100644 index ed5c5aec6..000000000 --- a/src/components/ui/view-toggle.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -export interface ViewToggleSegment { - value: T; - label: string; - icon: LucideIcon; -} - -export interface ViewToggleProps { - view: T; - onChange: (view: T) => void; - segments: ViewToggleSegment[]; - /** Accessible name for the segmented-control group. */ - groupLabel: string; - /** `data-slot` prefix per segment, rendered as `${dataSlotPrefix}-${value}`. */ - dataSlotPrefix: string; -} - -/** - * Shared icon-only segmented view toggle (e.g. cards vs table/list). A small - * two-segment control: the active segment carries the filled surface, the - * inactive one stays ghost. `aria-pressed` announces the state per segment; - * the group is named for screen readers. Segments keep the 44-px mobile tap - * floor (`size-11`), shrinking to the 36-px control height on desktop - * (`sm:size-9`) like a neighbouring Add button — the same floor the intake - * kebab and history-rail delete controls raised in this release window - * (L3, `.planning/audits/2026-07-18-qa-ui.md`). - * - * Previously forked between the medications module - * (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs - * settings pages (`module-list/module-view-toggle.tsx`) — verbatim - * copy-paste that only diverged in icons + i18n keys and had already - * drifted. Both are now thin wrappers over this one primitive. - */ -export function ViewToggle({ - view, - onChange, - segments, - groupLabel, - dataSlotPrefix, -}: ViewToggleProps) { - return ( -
    - {segments.map(({ value, label, icon: Icon }) => { - const active = view === value; - return ( - - ); - })} -
    - ); -} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index af771e78b..361657bba 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -44,6 +44,7 @@ import { CatalogPicker } from "./catalog-picker"; import { CustomVaccineSheet } from "./custom-vaccine-sheet"; import { VaccinationDocumentPicker } from "./vaccination-document-picker"; import { dayKeyAsUtcMidnight, isCalendarDateKey } from "@/lib/tz/date-only"; +import { InlineAction } from "@/components/ui/inline-action"; /** The seven anatomical sites, mirroring the Prisma `VaccinationSite` enum. */ export const VACCINATION_SITES = [ @@ -312,14 +313,14 @@ export function VaccinationForm({
    ) : ( - + )} diff --git a/src/hooks/use-coach-prefs.ts b/src/hooks/use-coach-prefs.ts index fc1bdb366..025e0cf40 100644 --- a/src/hooks/use-coach-prefs.ts +++ b/src/hooks/use-coach-prefs.ts @@ -9,10 +9,8 @@ import { withBaseToken, isConflict, } from "@/lib/api/optimistic-token"; -import { - DEFAULT_COACH_PREFS, - type CoachPrefs, -} from "@/lib/validations/coach-prefs"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; +import { DEFAULT_COACH_PREFS } from "@/lib/ai/coach/prefs-constants"; /** * v1.32.22 — the cached Coach-prefs row carries the optimistic- diff --git a/src/lib/ai/coach/clusters.ts b/src/lib/ai/coach/clusters.ts index 0dfba9374..9f52a58c4 100644 --- a/src/lib/ai/coach/clusters.ts +++ b/src/lib/ai/coach/clusters.ts @@ -19,7 +19,7 @@ import { DEFAULT_COACH_CLUSTERS, type CoachDataCluster, -} from "@/lib/validations/coach-prefs"; +} from "@/lib/ai/coach/prefs-constants"; import type { CoachScopeSource } from "./types"; /** diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts index 5ed803f7d..091b17a52 100644 --- a/src/lib/ai/coach/dialog-keys.ts +++ b/src/lib/ai/coach/dialog-keys.ts @@ -15,16 +15,16 @@ */ import type { Locale } from "@/lib/i18n/config"; import { pluralKey } from "@/lib/i18n/plural"; -import { - coachScopeSourceSchema, - type CoachFollowUpKind, - type CoachMethodEntry, - type CoachResultGranularity, - type CoachResultPeriod, - type CoachScopeWindow, - type CoachStepDomain, - type CoachStepReason, +import type { + CoachFollowUpKind, + CoachMethodEntry, + CoachResultGranularity, + CoachResultPeriod, + CoachScopeWindow, + CoachStepDomain, + CoachStepReason, } from "@/lib/ai/coach/types"; +import { COACH_SCOPE_SOURCES } from "@/lib/ai/coach/scope-sources"; // ── Steps ───────────────────────────────────────────────────────────────── @@ -70,9 +70,7 @@ export const COACH_STEP_REASON_KEYS: Readonly> = invalid_arguments: "coach.step.reason.invalidArguments", }; -const SCOPE_SOURCES: ReadonlySet = new Set( - coachScopeSourceSchema.options, -); +const SCOPE_SOURCES: ReadonlySet = new Set(COACH_SCOPE_SOURCES); /** * A domain's display name. A measurement-backed scope source reuses the diff --git a/src/lib/ai/coach/prefs-constants.ts b/src/lib/ai/coach/prefs-constants.ts new file mode 100644 index 000000000..fde317c4e --- /dev/null +++ b/src/lib/ai/coach/prefs-constants.ts @@ -0,0 +1,66 @@ +/** + * The Coach preference values a client component needs, without the schema + * library: the data clusters, the cluster and preference defaults, the + * reasoning-level reader. `src/lib/validations/coach-prefs.ts` builds its zod + * schemas from these and re-exports them for server code. + */ +import { + DEFAULT_REASONING_LEVEL, + type ReasoningLevel, +} from "@/lib/ai/reasoning/levels"; +import type { CoachPrefs } from "@/lib/validations/coach-prefs"; + +/** + * The data clusters the Coach may read. The Coach applies the selected + * clusters when a turn does not carry an explicit `scope.sources` list, then + * subtracts `excludeMetrics` as a post-filter. + */ +export const COACH_DATA_CLUSTERS = [ + "cardio", + "body", + "activity", + "workouts", + "sleep", + "mood", + "glucose", + "medication", + "mobility", + "environment", +] as const; +export type CoachDataCluster = (typeof COACH_DATA_CLUSTERS)[number]; + +/** + * Clusters enabled when the user has never touched the cluster picker + * (`dataClusters === undefined`). These four reproduce today's legacy + * five domains: cardio carries BP + pulse, body carries weight, mood + * and medication map straight through. The additive members riding + * inside cardio/body (HRV, resting HR, body-composition) only surface + * when the user actually has rows for them — empty blocks are dropped — + * so a web-only account stays close to the legacy 5-domain output while + * an iOS account quietly gains the extra signals it already stores. + */ +export const DEFAULT_COACH_CLUSTERS: ReadonlyArray = [ + "cardio", + "body", + "mood", + "medication", +]; + +/** + * Default preferences applied when the user has never opened the + * settings cog. Equivalent to `coachPrefsSchema.parse({})` but + * returned as a plain object so call sites can compare references + * (e.g., short-circuit a snapshot rebuild when prefs match defaults). + */ +export const DEFAULT_COACH_PREFS: CoachPrefs = { + tone: "warm", + verbosity: "default", + excludeMetrics: [], + showEvidenceByDefault: false, + defaultWindow: "allTime", +}; + +/** The person's reasoning level; an absent key is the default, `medium`. */ +export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel { + return prefs.reasoning ?? DEFAULT_REASONING_LEVEL; +} diff --git a/src/lib/ai/coach/scope-sources.ts b/src/lib/ai/coach/scope-sources.ts index faab9bc3c..e070a61bf 100644 --- a/src/lib/ai/coach/scope-sources.ts +++ b/src/lib/ai/coach/scope-sources.ts @@ -59,3 +59,10 @@ export const COACH_SCOPE_SOURCES = [ // Workout model (read from the `Workout` table, not `Measurement`). "workouts", ] as const; + +/** + * Maximum user-visible length for generated and explicitly renamed + * conversation titles. Here rather than in the schema module so the rename + * field reads it without the schema library. + */ +export const COACH_CONVERSATION_TITLE_MAX = 80; diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 3c4f564b5..f6bf822e2 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -919,8 +919,7 @@ export interface CoachConversationAttachmentDTO { title: string | null; } -/** Maximum user-visible length for generated and explicitly renamed titles. */ -export const COACH_CONVERSATION_TITLE_MAX = 80; +export { COACH_CONVERSATION_TITLE_MAX } from "./scope-sources"; export interface CoachConversationDTO { id: string; diff --git a/src/lib/documents/inbound-constants.ts b/src/lib/documents/inbound-constants.ts new file mode 100644 index 000000000..be54767d5 --- /dev/null +++ b/src/lib/documents/inbound-constants.ts @@ -0,0 +1,56 @@ +/** + * The document-vault constants a client component needs, without the schema + * library: the kind labels, the content-index sources, the list and bulk + * limits, the coach attachment cap. `src/lib/validations/inbound-documents.ts` + * builds its zod schemas from these and re-exports them, so server code keeps + * its import; client code imports from here (a guard holds the documents and + * Coach routes to that). + */ + +/** The document-kind labels (no interpretation — a label only). */ +export const INBOUND_DOCUMENT_KINDS = [ + "DOCTOR_REPORT", + "DISCHARGE_LETTER", + "LAB_RESULT", + "IMAGING", + "PRESCRIPTION", + "REFERRAL", + "INSURANCE", + "VACCINATION", + "SICK_NOTE", + "OTHER", +] as const; +export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; + +/** + * How a document's content index was produced (mirrors the server `source` + * column). `vision` means an AI provider read the original (the AI-first path); + * every other value is a provider-free local extraction. The UI reads it to + * tell an AI-read document from a locally-indexed one — the former already had + * the richer read, the latter can still be offered "Read with AI". + */ +export const DOCUMENT_CONTENT_INDEX_SOURCES = [ + "vision", + "text-ocr", + "local-pdf", + "local-ocr", +] as const; +export type DocumentContentIndexSourceValue = + (typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number]; + +/** True when the index was produced by an AI provider reading the original. */ +export function isAiReadSource( + source: DocumentContentIndexSourceValue | string | null, +): boolean { + return source === "vision"; +} + +export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; + +/** Max document ids one bulk request may touch. */ +export const DOCUMENT_BULK_MAX_IDS = 100; + +/** Max documents attachable to one coach conversation. Bounds prompt size + the + * numeric-grounding union; five labelled documents is already past the point a + * chat answer stays useful. */ +export const MAX_COACH_ATTACHMENTS = 5; diff --git a/src/lib/format/__tests__/bytes.test.ts b/src/lib/format/__tests__/bytes.test.ts new file mode 100644 index 000000000..0413d42c8 --- /dev/null +++ b/src/lib/format/__tests__/bytes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { formatBytes } from "../bytes"; + +describe("formatBytes", () => { + it("scales units and keeps one decimal below 100", () => { + expect(formatBytes(0, "en")).toBe("0 B"); + expect(formatBytes(512, "en")).toBe("512 B"); + expect(formatBytes(1_310_720, "en")).toBe("1.3 MB"); + expect(formatBytes(26_214_400, "en")).toBe("25 MB"); + expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB"); + expect(formatBytes(150 * 1024 * 1024, "en")).toBe("150 MB"); + }); + + it("prints the digits in the reader's locale", () => { + expect(formatBytes(1_288_490_189, "de")).toBe("1,2 GB"); + expect(formatBytes(1_288_490_189, "de-DE")).toBe("1,2 GB"); + expect(formatBytes(1_310_720, "fr")).toBe("1,3 MB"); + }); + + it("never prints a negative size", () => { + expect(formatBytes(-5, "en")).toBe("0 B"); + }); +}); + +/** + * One byte formatter. A second definition is how the admin area and the + * document vault came to print the same size two ways. + */ +describe("a single byte formatter", () => { + it("is defined only in src/lib/format/bytes.ts", () => { + const root = join(__dirname, "../../.."); + const hits: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name === "generated") continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) { + walk(path); + } else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) { + const source = readFileSync(path, "utf8"); + if (/function\s+formatBytes\b|const\s+formatBytes\s*=/.test(source)) { + hits.push(path.slice(root.length + 1)); + } + } + } + }; + walk(root); + expect(hits).toEqual(["lib/format/bytes.ts"]); + }); +}); diff --git a/src/lib/format/bytes.ts b/src/lib/format/bytes.ts index c59732ce2..7fd753fbe 100644 --- a/src/lib/format/bytes.ts +++ b/src/lib/format/bytes.ts @@ -1,22 +1,23 @@ -import type { useFormatters } from "@/lib/i18n/context"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; /** - * One spelling of "1.25 MB" for every surface that shows a byte count from - * the admin backups response. + * One spelling of a byte count for every surface: 0 B / 412 KB / 1.2 MB / + * 2.4 GB, digits in the reader's locale ("1,2 MB" for a German reader). * - * It lives here rather than beside the card that happened to need it first: - * the second reader was importing a formatter out of a thousand-line client - * component, which is a dependency on a page's worth of code for four lines of - * arithmetic. Both cards render on the same route today, so it costs nothing - * today — and it becomes a cost the moment either one moves. + * There used to be two: this module printed megabytes with two decimals and + * no gigabyte step, the document vault printed one decimal up to gigabytes, so + * the same backup read "1.25 MB" in the admin area and "1.2 MB" beside a + * document. It takes the locale rather than a formatter object so the share + * page, which renders on the server without the formatter hook, uses it too. */ -export function formatBytes( - bytes: number, - fmt: ReturnType, -): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) { - return `${fmt.integer(Math.round(bytes / 1024))} KB`; +export function formatBytes(bytes: number, locale: string): string { + const units = ["B", "KB", "MB", "GB"] as const; + let value = Math.max(0, bytes); + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; } - return `${fmt.number(bytes / 1024 / 1024, 2)} MB`; + const digits = unit === 0 || value >= 100 ? 0 : 1; + return `${getNumberFormat(locale, { maximumFractionDigits: digits }).format(value)} ${units[unit]}`; } diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index b0c71e16d..8d622dd9f 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -424,3 +424,16 @@ export function leafGroup(leaf: ReportLeafId): ReportGroupId { ? STRUCTURED_LEAF_GROUP[leaf] : MEASUREMENT_LEAF_GROUP[leaf]; } + +const LEAF_ORDER = new Map( + ALL_LEAF_IDS.map((leaf, index) => [leaf, index]), +); + +/** Stable ordering helper, exported for the panel's own state folding. */ +export function orderLeaves( + leaves: Iterable, +): readonly ReportLeafId[] { + return [...new Set(leaves)].sort( + (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0), + ); +} diff --git a/src/lib/report-selection/selection.ts b/src/lib/report-selection/selection.ts index 50adaaf96..d484e5221 100644 --- a/src/lib/report-selection/selection.ts +++ b/src/lib/report-selection/selection.ts @@ -14,7 +14,12 @@ */ import { z } from "zod/v4"; -import { isReportLeafId, type ReportLeafId, ALL_LEAF_IDS } from "./catalogue"; +import { + isReportLeafId, + orderLeaves, + type ReportLeafId, + ALL_LEAF_IDS, +} from "./catalogue"; declare const brand: unique symbol; @@ -40,10 +45,6 @@ export const reportSelectionSchema = z export type ReportSelectionInput = z.infer; -const LEAF_ORDER = new Map( - ALL_LEAF_IDS.map((leaf, index) => [leaf, index]), -); - function mint(leaves: Iterable): ReportSelection { const set = new Set(leaves); const ordered = ALL_LEAF_IDS.filter((leaf) => set.has(leaf)); @@ -121,11 +122,6 @@ export function isEmptySelection(selection: ReportSelection): boolean { return selection.leaves.length === 0; } -/** Stable ordering helper, exported for the panel's own state folding. */ -export function orderLeaves( - leaves: Iterable, -): readonly ReportLeafId[] { - return [...new Set(leaves)].sort( - (a, b) => (LEAF_ORDER.get(a) ?? 0) - (LEAF_ORDER.get(b) ?? 0), - ); -} +// The ordering helper lives beside the catalogue it orders, so a client +// panel that only folds its own state does not pull the schema library in. +export { orderLeaves }; diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index c7ad30325..34551646d 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -14,11 +14,22 @@ */ import { z } from "zod/v4"; +import { REASONING_LEVELS } from "@/lib/ai/reasoning/levels"; import { - DEFAULT_REASONING_LEVEL, - REASONING_LEVELS, - type ReasoningLevel, -} from "@/lib/ai/reasoning/levels"; + COACH_DATA_CLUSTERS, + DEFAULT_COACH_PREFS, +} from "@/lib/ai/coach/prefs-constants"; + +// The values a client component needs live in a zod-free module, so the +// Coach and the settings pages do not pull the schema library in with them. +// Re-exported here for the server code that imports them from the schema. +export { + COACH_DATA_CLUSTERS, + DEFAULT_COACH_CLUSTERS, + DEFAULT_COACH_PREFS, + coachReasoningLevel, + type CoachDataCluster, +} from "@/lib/ai/coach/prefs-constants"; /** * Tone presets the Coach system-prompt prefix toggles between. v1.4.22 @@ -99,36 +110,7 @@ export type CoachDefaultWindow = z.infer; * The cluster set is the opt-in source of truth going forward; * `excludeMetrics` stays valid for back-compat and only narrows. */ -export const coachDataClusterEnum = z.enum([ - "cardio", - "body", - "activity", - "workouts", - "sleep", - "mood", - "glucose", - "medication", - "mobility", - "environment", -]); -export type CoachDataCluster = z.infer; - -/** - * Clusters enabled when the user has never touched the cluster picker - * (`dataClusters === undefined`). These four reproduce today's legacy - * five domains: cardio carries BP + pulse, body carries weight, mood - * and medication map straight through. The additive members riding - * inside cardio/body (HRV, resting HR, body-composition) only surface - * when the user actually has rows for them — empty blocks are dropped — - * so a web-only account stays close to the legacy 5-domain output while - * an iOS account quietly gains the extra signals it already stores. - */ -export const DEFAULT_COACH_CLUSTERS: ReadonlyArray = [ - "cardio", - "body", - "mood", - "medication", -]; +export const coachDataClusterEnum = z.enum(COACH_DATA_CLUSTERS); /** * v1.18.1 — Coach cadence-suggestion state. Persisted @@ -216,25 +198,6 @@ export const coachPrefsSchema = z.object({ export type CoachPrefs = z.infer; -/** - * Default preferences applied when the user has never opened the - * settings cog. Equivalent to `coachPrefsSchema.parse({})` but - * returned as a plain object so call sites can compare references - * (e.g., short-circuit a snapshot rebuild when prefs match defaults). - */ -export const DEFAULT_COACH_PREFS: CoachPrefs = { - tone: "warm", - verbosity: "default", - excludeMetrics: [], - showEvidenceByDefault: false, - defaultWindow: "allTime", -}; - -/** The person's reasoning level; an absent key is the default, `medium`. */ -export function coachReasoningLevel(prefs: CoachPrefs): ReasoningLevel { - return prefs.reasoning ?? DEFAULT_REASONING_LEVEL; -} - /** * Parse a row's `coachPrefsJson` Json blob into a typed `CoachPrefs`, * falling back to defaults when the row is null. Keeps the call sites at diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 32e41d25b..253ff3bc3 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -25,9 +25,33 @@ * it is always narrowed from the session. */ import { z } from "zod/v4"; +import { + DOCUMENT_BULK_MAX_IDS, + DOCUMENT_CONTENT_INDEX_SOURCES, + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + MAX_COACH_ATTACHMENTS, + type DocumentContentIndexSourceValue, + type InboundDocumentKindValue, +} from "@/lib/documents/inbound-constants"; import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; +// The constants and label lists a client needs live in a zod-free module, so +// a component that wants the list of document kinds does not pull the schema +// library into its route. Re-exported here for the server code that already +// imports them from the schema module. +export { + DOCUMENT_BULK_MAX_IDS, + DOCUMENT_CONTENT_INDEX_SOURCES, + DOCUMENT_LIST_DEFAULT_LIMIT, + INBOUND_DOCUMENT_KINDS, + MAX_COACH_ATTACHMENTS, + isAiReadSource, + type DocumentContentIndexSourceValue, + type InboundDocumentKindValue, +} from "@/lib/documents/inbound-constants"; + /** Max facts a single document may stage / confirm. A dense letter is ~40. */ export const INBOUND_MAX_FACTS = 120; @@ -47,44 +71,6 @@ export const INBOUND_FACT_TYPES = [ ] as const; export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number]; -/** The document-kind labels (no interpretation — a label only). */ -export const INBOUND_DOCUMENT_KINDS = [ - "DOCTOR_REPORT", - "DISCHARGE_LETTER", - "LAB_RESULT", - "IMAGING", - "PRESCRIPTION", - "REFERRAL", - "INSURANCE", - "VACCINATION", - "SICK_NOTE", - "OTHER", -] as const; -export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number]; - -/** - * How a document's content index was produced (mirrors the server `source` - * column). `vision` means an AI provider read the original (the AI-first path); - * every other value is a provider-free local extraction. The UI reads it to - * tell an AI-read document from a locally-indexed one — the former already had - * the richer read, the latter can still be offered "Read with AI". - */ -export const DOCUMENT_CONTENT_INDEX_SOURCES = [ - "vision", - "text-ocr", - "local-pdf", - "local-ocr", -] as const; -export type DocumentContentIndexSourceValue = - (typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number]; - -/** True when the index was produced by an AI provider reading the original. */ -export function isAiReadSource( - source: DocumentContentIndexSourceValue | string | null, -): boolean { - return source === "vision"; -} - /** * Narrow the free-String DB `source` column to the DTO union, or `null` for an * absent / unrecognised value. The column is a free String server-side (adding @@ -880,7 +866,6 @@ export const DOCUMENT_LIST_SORTS = [ export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number]; export const DOCUMENT_LIST_MAX_LIMIT = 100; -export const DOCUMENT_LIST_DEFAULT_LIMIT = 50; /** * The library list query: title/filename search, category filter, a @@ -917,9 +902,6 @@ export type DocumentListQuery = z.infer; // ─── Vault: bulk actions ─────────────────────────────────────────────────── -/** Max document ids one bulk request may touch. */ -export const DOCUMENT_BULK_MAX_IDS = 100; - export const DOCUMENT_BULK_ACTIONS = [ "setKind", "linkEpisode", @@ -1110,11 +1092,6 @@ export type DocumentChatHistoryQuery = z.infer< // ─── Fenced multi-document coach chat (S7) ────────────────────────────────── -/** Max documents attachable to one coach conversation. Bounds prompt size + the - * numeric-grounding union; five labelled documents is already past the point a - * chat answer stays useful. */ -export const MAX_COACH_ATTACHMENTS = 5; - /** * Request body for `POST /api/insights/chat/fenced`. `.strict()` — the schema * REFUSES the tool-mode fields (`scope` / `guidedQuestion` / `prefill`) and