Merge branch 'fix/v1438-ui' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 14:09:44 +02:00
commit 08987e2cb2
243 files changed
+4652 -3176

No files matched your search

@@ -0,0 +1,86 @@
/**
* Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a
* <Card> 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: '<Card className="gap-3" />', filename: APP_FILE },
// An inner tile: another radius.
{
code: '<div className="bg-card border-border rounded-lg border p-3" />',
filename: APP_FILE,
},
// The translucent hero glass and inner washes are not the card colour.
{
code: '<div className="bg-card/65 rounded-xl border p-4 backdrop-blur-sm" />',
filename: APP_FILE,
},
// No border: not a shell.
{ code: '<div className="bg-card rounded-xl p-4" />', filename: APP_FILE },
// The primitives under src/components/ui own the shell.
{
code: '<div className="bg-card border rounded-xl p-4" />',
filename: "/repo/src/components/ui/trend-tile.tsx",
},
// An allowed file.
{
code: '<div className="bg-card border rounded-xl p-4" />',
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: '<div className="bg-card border rounded-xl" />',
filename: "/repo/src/components/__tests__/x.test.tsx",
},
],
invalid: [
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
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: '<div className="bg-card border rounded-xl" />',
filename: APP_FILE,
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
errors: [{ messageId: "rawShell" }],
},
],
});
@@ -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: "<span>{total.toLocaleString()}</span>",
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"]);
});
});
+9
View File
@@ -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 <Card> 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,
},
};
@@ -0,0 +1,107 @@
/**
* @fileoverview ESLint rule — a card surface is a `<Card>` (or one of the
* shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`.
*
* UI standards §1: "New surfaces compose `<Card>` (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 <Card> (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 <Card> (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;
@@ -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:
*
* - `<x>.toFixed(…)` and `<x>.toPrecision(…)`, unless the call is the
* argument of `Number(…)` / `parseFloat(…)` / `Number.parseFloat(…)` or
* the operand of a unary `+`
* - `<x>.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 -- <why>
*
* 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 -- <reason>`.",
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: `-- <reason>` 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;
+54
View File
@@ -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 ... -- <reason>`;
// the rule refuses a directive without one. See the rule header.
"healthlog/no-unlocalised-decimal": "error",
// A card surface is a <Card> (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
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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": "단계별 증상 패턴",
+4 -15
View File
@@ -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",
+4 -15
View File
@@ -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",
@@ -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",
@@ -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<string, boolean>();
const parent = new Map<string, string>();
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"]);
});
});
+107
View File
@@ -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 `<p>`, `<div>` or `<span>` whose class list carries
* `text-muted-foreground` and whose only child is `{t("<key>")}` 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 <EmptyState variant="plain" size="compact"> (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:
'<p className="text-muted-foreground text-sm">{t("admin.aiQuality.empty")}</p>',
},
{
path: "components/b.tsx",
source:
'<p className="text-muted-foreground text-xs">{t("cycle.sheet.emptyHint")}</p>',
},
{
path: "components/c.tsx",
source: '<EmptyState title={t("labs.detail.emptyTitle")} />',
},
]),
).toEqual(["components/a.tsx: admin.aiQuality.empty"]);
});
});
+121
View File
@@ -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 `<button>`, `<Link>` or `<a>` whose static `className` combines
* the inline-action signature — an underline (`underline` / `hover:underline`)
* with a small text size (`text-xs` / `text-sm`) and an action tone
* (`text-primary` / `text-muted-foreground`). A full-width row link or a
* framed button does not have that combination.
*
* Not matched (stated, not hidden): a class list assembled at runtime, and a
* copy without an underline. The guard catches the idiom, not every way to
* draw a small link.
*/
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, "..");
/**
* Sites that look like an inline action but are something else, each with
* the reason. Keyed by `path:tag`.
*/
const ALLOWED: Record<string, string> = {
// A full-width footer row of the glucose clinical panel: icon plus a
// wrapping sentence under a border, read as part of the card body.
"components/insights/glucose/glucose-clinical-panel.tsx:Link":
"full-width footer row, not an inline action",
};
const OPENER_RE = /<(button|Link|a)\b((?:[^>{]|\{[^}]*\})*?)>/g;
const CLASS_RE = /className=(?:"([^"]*)"|\{cn\(\s*"([^"]*)")/;
function isInlineActionShape(cls: string): boolean {
return (
/(?:^|\s)(?:hover:)?underline(?:\s|$)/.test(cls) &&
/(?:^|\s)text-(?:xs|sm)(?:\s|$)/.test(cls) &&
/(?:^|\s)text-(?:primary|muted-foreground)(?:\s|$)/.test(cls)
);
}
function findHandRolledInlineActions(
files: { path: string; source: string }[],
): string[] {
const hits: string[] = [];
for (const { path, source } of files) {
for (const match of source.matchAll(OPENER_RE)) {
const cls = CLASS_RE.exec(match[2]);
if (!cls) continue;
if (!isInlineActionShape(cls[1] ?? cls[2] ?? "")) continue;
hits.push(`${path}:${match[1]}`);
}
}
return hits;
}
describe("small text actions go through InlineAction", () => {
const files = walkSourceFiles(SRC, { floor: 1200, extensions: [".tsx"] })
.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(800);
});
it("has no hand-rolled copy outside the allowlist", () => {
const hits = findHandRolledInlineActions(files).filter(
(key) => !(key in ALLOWED),
);
expect(hits).toEqual([]);
});
it("keeps every allowlist entry live", () => {
const hits = new Set(findHandRolledInlineActions(files));
for (const key of Object.keys(ALLOWED)) {
expect(hits.has(key), key).toBe(true);
}
});
it("recognises the idiom it exists to refuse", () => {
const planted = [
{
path: "components/x.tsx",
source:
'<Link href="/a" className="text-primary text-xs underline-offset-2 hover:underline">Go</Link>',
},
{
path: "components/y.tsx",
source:
'<button type="button" onClick={() => go()} className="text-muted-foreground hover:text-foreground text-sm underline">Go</button>',
},
{
path: "components/z.tsx",
source: '<InlineAction tone="muted">Go</InlineAction>',
},
];
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("<InlineAction"));
expect(users.length).toBeGreaterThanOrEqual(25);
});
});
@@ -0,0 +1,85 @@
/**
* A module page's title is a `PageHeader` (UI standards §1, §5): the H1 on
* one tier (`text-2xl font-bold tracking-tight`), its explainer muted under
* it, its actions beside it.
*
* Four in-app headings were hand-rolled beside it — the insights sub-page
* shell (`text-xl sm:text-2xl font-semibold`), the medication detail, the
* check-in and its result (`font-semibold`) — and each read a step lighter
* than every other page title. They go through `PageHeader` now; this guard
* keeps a new `<h1>` literal from appearing outside the places where a
* different heading is the point.
*
* Matched: an `<h1` tag in JSX under `src/components` and `src/app`. A
* mention in a comment counts too, which is why the files that only talk
* about an H1 are listed with that reason.
*/
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<string, string> = {
"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("<h1"),
);
it("finds the H1 sites at all", () => {
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);
});
});
+98
View File
@@ -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<string, string> = {
// 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<string>();
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" }; <span className={cn("shrink-0 rounded-full px-2 py-0.5 text-xs font-medium bg-info/15 text-info")} />',
},
{
path: "components/b.tsx",
source:
'<span className="bg-muted rounded-full px-2 py-0.5 text-xs" />',
},
]),
).toEqual(["components/a.tsx"]);
});
it("is used where the copies were", () => {
const users = files.filter((f) => f.source.includes("<ScorePill"));
expect(users.length).toBeGreaterThanOrEqual(8);
});
});
+5 -20
View File
@@ -164,7 +164,6 @@ const PINNED_AFFORDANCES: Record<
// behind the delete toast's Undo action.
"toast.success": 3,
},
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
// v1.36.x — two call sites, one decision: the intake toast routes through
// `intakeToastOptions()`, which returns `undefined` in the caller's own
// record, and sonner's one-argument form is what the unit tests assert. So
@@ -214,25 +213,10 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/coach-panel/suggested-action-card.tsx": {
"text-success": 1,
},
"src/components/insights/confidence-badge.ts": { "text-success": 1 },
"src/components/insights/daily-briefing.tsx": { "text-success": 1 },
"src/components/insights/derived/band-tokens.ts": { "text-success": 1 },
// The green `+N` chip beside the Health Score label. It reports a week that
// went up, not an action that succeeded, and it wears the same band token
// the score's own number does — there is no outcome here to route through
// the outcome module.
"src/components/insights/health-score-card.tsx": { "text-success": 1 },
"src/components/insights/insights-edit-mode.tsx": { "toast.success": 2 },
"src/components/insights/insights-tab-strip.tsx": { "toast.success": 1 },
"src/components/insights/mood/mood-better-days.tsx": { "text-success": 1 },
"src/components/insights/mood/mood-factor-metric-crosstab.tsx": {
"text-success": 1,
},
"src/components/insights/mood/mood-tag-influence.tsx": { "text-success": 1 },
"src/components/insights/mood/mood-tag-metric-crosstab.tsx": {
"text-success": 1,
},
"src/components/insights/personal-record-badge.tsx": { "text-success": 1 },
"src/components/labs/biomarker-form.tsx": { "toast.success": 1 },
"src/components/labs/biomarker-manager.tsx": { "toast.success": 2 },
"src/components/labs/lab-biomarker-detail.tsx": { "toast.success": 1 },
@@ -369,10 +353,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
},
"src/components/settings/integration-status-pill.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/integrations/connections-panel.tsx": {
"toast.success": 2,
},
@@ -430,6 +410,11 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
// up). It states a grade beside a reading, never that a write succeeded;
// the crosstab, confidence and score chips that each carried their own copy
// of this class now render through it.
"src/components/ui/score-pill.tsx": { "text-success": 1 },
};
/**
+7 -6
View File
@@ -43,6 +43,7 @@ import {
type AchievementCategory,
type AchievementProgress,
} from "@/lib/gamification/achievements";
import { TrendTile } from "@/components/ui/trend-tile";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -351,7 +352,7 @@ export default function AchievementsPage() {
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<TrendTile className="min-h-34 justify-between">
<p className="text-muted-foreground text-sm">
{t("achievements.points")}
</p>
@@ -370,8 +371,8 @@ export default function AchievementsPage() {
className="h-1.5"
aria-label={t("achievements.points")}
/>
</div>
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
</TrendTile>
<TrendTile className="min-h-34 justify-between">
<p className="text-muted-foreground text-sm">
{t("achievements.unlocked")}
</p>
@@ -384,8 +385,8 @@ export default function AchievementsPage() {
<p className="text-muted-foreground text-xs">
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
</p>
</div>
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
</TrendTile>
<TrendTile className="min-h-34 sm:col-span-2 lg:col-span-1">
<p className="text-muted-foreground text-sm">
{t("achievements.nextGoal")}
</p>
@@ -428,7 +429,7 @@ export default function AchievementsPage() {
className="mt-3 h-1.5"
aria-label={t("achievements.nextProgressLabel")}
/>
</div>
</TrendTile>
</div>
{grouped.length === 0 ? (
+9 -7
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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"',
+1 -1
View File
@@ -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.
<Card data-slot="bp-derived-caption" className="gap-2 py-3 md:py-4">
<Card data-slot="bp-derived-caption" density="compact">
<CardContent>
<p className="text-sm leading-relaxed">
{t("insights.subPage.bpDerived.pulsePressure", {
+1 -5
View File
@@ -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 (
<Card
key={group}
data-slot="catalog-group"
className="gap-2 py-3 md:py-4"
>
<Card key={group} data-slot="catalog-group" density="compact">
<CardHeader>
<TileHeader
icon={Icon}
+18 -13
View File
@@ -9,6 +9,7 @@
* fetch; Next.js renders it instantly while the segment streams.
*/
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
export default function InsightsLoading() {
return (
<div data-slot="insights-subpage-loading" className="space-y-6">
@@ -18,21 +19,25 @@ export default function InsightsLoading() {
</header>
{/* Chart card */}
<div className="bg-card rounded-xl border p-4">
<Skeleton className="bg-muted mb-4 h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</div>
<Card>
<CardContent className="space-y-4">
<Skeleton className="bg-muted h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</CardContent>
</Card>
{/* Assessment card */}
<div className="bg-card space-y-2 rounded-xl border p-4">
<div className="flex items-center gap-2">
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
</div>
<Card>
<CardContent className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
</CardContent>
</Card>
</div>
);
}
+2 -6
View File
@@ -248,7 +248,7 @@ export default function InsightsMedikamentePage() {
med.categoryLabel,
);
return (
<Card key={med.id} className="gap-2 py-3 md:py-4">
<Card key={med.id} density="compact">
<CardHeader>
<TileHeader icon={Pill} title={med.name} />
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
@@ -394,11 +394,7 @@ function MedicationComplianceCalendar({
}
if (!data?.dailyCompliance) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
{t("insights.complianceNoData")}
</div>
);
return <EmptyState size="compact" title={t("insights.complianceNoData")} />;
}
return (
+11 -21
View File
@@ -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 `<TileHeader>` 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 ? (
<Link
<CrossLinkCard
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none md:p-6"
>
<TileHeader
icon={Gauge}
title={t("insights.vo2Max.cardioLinkTitle")}
/>
<span className="text-muted-foreground block text-xs leading-snug">
{t("insights.vo2Max.cardioLinkBody")}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.vo2Max.cardioLinkCta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
slot="vo2-cardio-link"
icon={Gauge}
title={t("insights.vo2Max.cardioLinkTitle")}
body={t("insights.vo2Max.cardioLinkBody")}
cta={t("insights.vo2Max.cardioLinkCta")}
/>
) : null}
</SubPageShell>
);
+7 -5
View File
@@ -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() {
/>
</>
) : (
<p
<EmptyState
variant="plain"
size="compact"
title={t("insights.workouts.filterEmpty")}
className="px-4 md:px-6"
data-slot="workout-list-filter-empty"
className="text-muted-foreground px-4 text-sm md:px-6"
>
{t("insights.workouts.filterEmpty")}
</p>
/>
)}
</>
)}
+7 -8
View File
@@ -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 ? (
<Link
href="/mental-wellbeing"
data-slot="mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild tone="muted" size="sm" className="gap-1.5">
<Link href="/mental-wellbeing" data-slot="mood-mental-wellbeing-link">
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
) : null}
</div>
);
+18 -12
View File
@@ -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 (
<div
key={eventType}
className="bg-card border-border rounded-xl border p-4"
>
<div className="mb-3">
<div className="text-sm font-medium">{t(nameKey)}</div>
<div className="text-muted-foreground text-xs">
<Card key={eventType} className="gap-3">
<CardHeader className="gap-1">
<CardTitle className="text-sm font-medium">
{t(nameKey)}
</CardTitle>
<CardDescription className="text-xs">
{t(descKey)}
</div>
</div>
<div className="space-y-2">
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled;
@@ -355,8 +361,8 @@ export default function NotificationsPage() {
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
+6 -3
View File
@@ -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 ? (
<p className="text-muted-foreground text-sm">
{t("admin.aiQuality.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.aiQuality.empty")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="ai-quality-table">
+3 -3
View File
@@ -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() {
</Badge>
</td>
<td className="px-3 py-2 text-right font-mono text-xs tabular-nums">
{formatBytes(row.sizeBytes, fmt)}
{formatBytes(row.sizeBytes, locale)}
</td>
<td className="text-muted-foreground px-3 py-2 text-right text-xs whitespace-nowrap">
{fmt.dateTime(row.createdAt)}
@@ -1394,7 +1394,7 @@ export function BackupsSection() {
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.colSize")}:{" "}
{formatBytes(row.sizeBytes, fmt)} ·{" "}
{formatBytes(row.sizeBytes, locale)} ·{" "}
{fmt.dateTime(row.createdAt)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
@@ -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")}
<div className="bg-card border-border mt-2 inline-flex items-center gap-2 rounded border px-3 py-2 font-mono text-lg tracking-widest">
{deviceCode.userCode}
<button
type="button"
<InlineAction
tone="muted"
className="underline"
onClick={() =>
navigator.clipboard?.writeText(deviceCode.userCode)
}
className="text-muted-foreground hover:text-foreground text-xs underline"
>
{t("settings.ai.deviceCodeCopy")}
</button>
</InlineAction>
</div>
</li>
<li>{t("settings.ai.deviceCodeStep3")}</li>
@@ -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 ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.coachFeedback.empty")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-slot="coach-feedback-table">
+12 -12
View File
@@ -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() {
/>
<Stat
label={t("admin.section.encryption.totalRows")}
value={s.totalRows.toLocaleString()}
value={fmt.integer(s.totalRows)}
/>
<Stat
label={t("admin.section.encryption.staleRows")}
value={s.staleRows.toLocaleString()}
value={fmt.integer(s.staleRows)}
/>
</div>
{/* "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 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
<EmptyState
variant="plain"
size="compact"
title={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({
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">
{active.toLocaleString()}
</td>
<td className="py-2 pr-3">{fmt.integer(c.total)}</td>
<td className="py-2 pr-3">{fmt.integer(active)}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
{fmt.integer(stale)}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
@@ -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 = () => {
+15 -8
View File
@@ -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() {
</span>
}
/>
<p className="text-muted-foreground text-xs">
{t("admin.hostMetrics.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.hostMetrics.empty")}
/>
</SettingsCard>
);
}
@@ -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<typeof useFormatters>,
): 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() {
<div className="grid gap-3 sm:grid-cols-2">
<StatTile
label={t("admin.section.maintenance.tableSize")}
value={sizeLabel(s.sizes.tableBytes, fmt)}
value={formatBytes(s.sizes.tableBytes, locale)}
/>
<StatTile
label={t("admin.section.maintenance.indexSize")}
value={sizeLabel(s.sizes.indexBytes, fmt)}
value={formatBytes(s.sizes.indexBytes, locale)}
/>
</div>
) : null}
@@ -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
/>
<Link
href="/admin/coach"
className="text-primary text-xs underline-offset-4 hover:underline"
>
{t("admin.section.coach.title")}
</Link>
<InlineAction asChild>
<Link href="/admin/coach">
{t("admin.section.coach.title")}
</Link>
</InlineAction>
</div>
);
}
@@ -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,
})}`}
@@ -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 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.providerHealth.empty")}
/>
) : (
<ul className="flex flex-col gap-3" data-testid="provider-health-list">
{providers.map((p) => (
@@ -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={
<Link
href="/admin/login-overview"
className="text-primary inline-flex items-center gap-1 text-sm underline-offset-2 hover:underline"
>
{t("admin.overview.auditViewAll")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
<InlineAction asChild size="sm">
<Link href="/admin/login-overview">
{t("admin.overview.auditViewAll")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
</InlineAction>
}
/>
+6 -3
View File
@@ -27,6 +27,7 @@ import {
describeChannelTestFailures,
type ChannelTestResult,
} from "./notification-test-failures";
import { EmptyState } from "@/components/ui/empty-state";
interface NotificationHealth {
windowHours: number;
@@ -73,9 +74,11 @@ function NotificationHealthPanel() {
)}
{data && data.channels.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.notificationHealth.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("admin.notificationHealth.empty")}
/>
)}
{data && data.channels.length > 0 && (
@@ -29,6 +29,7 @@ import {
useSystemStatus,
type FailingQueue,
} from "./_shared";
import { EmptyState } from "@/components/ui/empty-state";
// v1.4.16: Recharts is ~108 KiB Brotli — defer-load the host
// metrics chart so the system-status page only ships it when an admin
@@ -89,12 +90,14 @@ export function FailingJobsCard({
description={t("admin.failingJobs.description")}
/>
{failingJobs.queues.length === 0 ? (
<p
<EmptyState
variant="plain"
size="compact"
title={t("admin.failingJobs.none", {
hours: failingJobs.windowHours,
})}
data-testid="failing-jobs-none"
className="text-muted-foreground text-sm"
>
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
</p>
/>
) : (
<ul data-testid="failing-jobs-list" className="space-y-3">
{failingJobs.queues.map((queue) => (
@@ -162,7 +162,8 @@ export function UserManagementSection() {
setEditQuotaGb(
u.documentQuotaBytes === null
? ""
: String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 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((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
);
}
+1 -1
View File
@@ -138,7 +138,7 @@ export function AiConsentRequest({
};
return (
<Card data-slot="ai-consent-request" className="gap-2 py-3 md:py-4">
<Card data-slot="ai-consent-request" density="compact">
<CardHeader>
<TileHeader
icon={ShieldCheck}
@@ -268,7 +268,7 @@ function VisitRow({ t, visit }: { t: Translate; visit: EncounterDTO }) {
const site = bodySiteText(t, visit.bodySite, visit.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
density="compact"
data-slot="body-site-visit"
data-encounter-id={visit.id}
>
@@ -326,7 +326,7 @@ function ConditionRow({
const site = bodySiteText(t, condition.bodySite, condition.laterality);
return (
<Card
className="gap-2 py-3 md:py-4"
density="compact"
data-slot="body-site-condition"
data-episode-id={condition.id}
>
@@ -573,9 +573,7 @@ export function MedicationComplianceChart({
}
/>
) : !hasData ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
<ChartEmptyState title={t("charts.noData")} height={192} />
) : chartData.length < 3 ? (
// v1.4.16 — sparse-data placeholder consistent with the
// BP/weight/pulse/mood charts.
+12 -9
View File
@@ -20,7 +20,11 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
import { TileHeader } from "@/components/insights/tile-header";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import {
useDateFormatPreference,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -299,6 +303,7 @@ export function MoodChart({
}: MoodChartProps) {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const fmt = useFormatters();
// Issue #490 — x-axis tick + tooltip date labels. Every timestamp this
// chart formats encodes a CALENDAR DAY (noon-UTC of a server day key,
// or a `bucketTimeSeries` Berlin-day UTC-midnight bucket start), so
@@ -618,9 +623,9 @@ export function MoodChart({
};
const formatTooltipValue = (value: number): string => {
const rounded = Math.round(value * 10) / 10;
const rounded = fmt.number(Math.round(value * 10) / 10);
const label = moodLabels[Math.round(value)];
return label ? `${rounded} (${label})` : String(rounded);
return label ? `${rounded} (${label})` : rounded;
};
// v1.4.16 — personal mood baseline (median over visible window).
@@ -681,7 +686,7 @@ export function MoodChart({
if (scores.length === 0) {
return t("charts.a11y.noData", { metric });
}
const fmtScore = (v: number) => String(Math.round(v * 10) / 10);
const fmtScore = (v: number) => fmt.number(Math.round(v * 10) / 10);
const minVal = Math.min(...scores);
const maxVal = Math.max(...scores);
const latestVal = scores[scores.length - 1];
@@ -727,9 +732,7 @@ export function MoodChart({
height={CHART_HEIGHT_PX}
/>
) : !chartData?.length ? (
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
{t("charts.noData")}
</div>
<ChartEmptyState title={t("charts.noData")} height={192} />
) : chartData.length < 3 ? (
// v1.4.16 — sparse-data placeholder consistent with the
// BP/weight/pulse charts. Height tracks the chart strip's
@@ -938,7 +941,7 @@ export function MoodChart({
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`;
delta = t(
effectiveCompareBaseline === "lastMonth"
? "comparison.deltaVs.lastMonth"
@@ -951,7 +954,7 @@ export function MoodChart({
delta = t("charts.deltaUnchanged");
} else {
const sign = diff > 0 ? "+" : "−";
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
const formatted = `${sign}${fmt.number(Math.abs(diff), 1)}`;
delta = t("charts.deltaVsBaseline").replace(
"{delta}",
formatted,
+9 -4
View File
@@ -6,7 +6,11 @@ import { useCalendarDate } from "@/hooks/use-calendar-date";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { ChartDataTable } from "./chart-data-table";
import { useState, useMemo, useEffect, useRef } from "react";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import { heatmapDays } from "@/lib/charts/heatmap-days";
import { moodLabelKeyForScore } from "@/lib/mood/labels";
@@ -144,6 +148,7 @@ export function MoodHeatmap({
stretch = false,
}: MoodHeatmapProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const timeZone = useDisplayTimezone();
// Day keys are calendar dates ("YYYY-MM-DD"), read as such in every zone.
// A noon-UTC instant formatted in the reader's zone was a day late from
@@ -274,7 +279,7 @@ export function MoodHeatmap({
}
const labelKey = moodLabelKeyForScore(Math.round(cell.cell.score));
const moodLabel = labelKey ? t(labelKey) : "";
return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
return `${formatDay(cell.dateKey)}: ${fmt.number(cell.cell.score, 1)}${moodLabel ? ` · ${moodLabel}` : ""}`;
};
// Only the logged days go into the data table; the empty-tinted cells
// are already covered by the aggregate summary on the SVG, so a rotor
@@ -420,8 +425,8 @@ export function MoodHeatmap({
formatValue={(value) => {
const labelKey = moodLabelKeyForScore(Math.round(value));
return labelKey
? `${value.toFixed(1)} · ${t(labelKey)}`
: value.toFixed(1);
? `${fmt.number(value, 1)} · ${t(labelKey)}`
: fmt.number(value, 1);
}}
formatDate={(date) => formatDay(dateOnlyKey(date))}
bucket="day"
@@ -1,5 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { TrendTile } from "@/components/ui/trend-tile";
/**
* v1.16.0 — structured loading silhouette for one dashboard trend tile.
@@ -23,15 +24,12 @@ import { cn } from "@/lib/utils";
*/
export function TrendCardSkeleton({ className }: { className?: string }) {
return (
<div
<TrendTile
aria-hidden="true"
data-slot="trend-card-skeleton"
className={cn(
// Same outer chrome as `<TrendCard>` plus the `min-h-[8rem]`
// floor the legacy empty silhouette reserved.
"bg-card border-border flex h-full min-h-[8rem] w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6",
className,
)}
// The frame `<TrendCard>` uses, plus the `min-h-[8rem]` floor the
// legacy empty silhouette reserved.
className={cn("min-h-[8rem]", className)}
>
{/* Heading row — label + icon (TrendCard pins this row at h-5). */}
<div className="flex h-5 items-center justify-between gap-2">
@@ -51,6 +49,6 @@ export function TrendCardSkeleton({ className }: { className?: string }) {
<Skeleton className="h-3 w-14" />
<Skeleton className="h-3 w-14" />
</div>
</div>
</TrendTile>
);
}
+3 -2
View File
@@ -16,6 +16,7 @@ import {
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import { TrendTile } from "@/components/ui/trend-tile";
interface SecondaryMetric {
/** Sub-value latest reading (e.g. diastolic when latest is systolic). */
@@ -204,7 +205,7 @@ export function TrendCard({
};
return (
<div className="bg-card border-border flex h-full w-full min-w-0 flex-col overflow-hidden rounded-xl border p-4 md:p-6">
<TrendTile data-slot="trend-card">
{/* v1.4.25 — single-line discipline + deterministic height for
the heading row so the value row below sits at the same baseline
across every tile. The label is locale-abbreviated upstream (see
@@ -465,6 +466,6 @@ export function TrendCard({
sub-rows. */}
</div>
</TooltipProvider>
</div>
</TrendTile>
);
}
@@ -10,7 +10,7 @@ import {
ResponsiveContainer,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
@@ -35,6 +35,7 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
@@ -74,7 +75,7 @@ export function WorkoutElevationChart({
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(v) => Number(v).toFixed(1)}
tickFormatter={(v) => fmt.number(Number(v), 1)}
tickLine={false}
axisLine={false}
/>
@@ -94,7 +95,7 @@ export function WorkoutElevationChart({
fontSize: "0.875rem",
}}
labelFormatter={(d) =>
`${Number(d).toFixed(2)} ${distance.displayUnit}`
`${fmt.number(Number(d), 2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} ${elevation.displayUnit}`,
+1 -1
View File
@@ -11,7 +11,7 @@
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import { formatBytes } from "@/lib/format/bytes";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
@@ -22,6 +22,7 @@ import {
type LeafScope,
type Translate,
} from "./report-sections";
import { EmptyState } from "@/components/ui/empty-state";
/**
* Only the three stored gender values get a label. A row on a clinical
@@ -240,9 +241,11 @@ export function EmergencySection({
{t("clinicianView.sectionUnavailable")}
</p>
) : rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("clinicianView.sectionEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("clinicianView.sectionEmpty")}
/>
) : (
rows.map((row) => (
<StatRow
+6 -3
View File
@@ -34,6 +34,7 @@ import {
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { EmptyState } from "@/components/ui/empty-state";
export type Translate = (
key: string,
@@ -185,9 +186,11 @@ export function LeafSection({
{t("clinicianView.sectionUnavailable")}
</p>
) : empty ? (
<p className="text-muted-foreground text-sm">
{t("clinicianView.sectionEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("clinicianView.sectionEmpty")}
/>
) : (
children
)}
@@ -13,7 +13,6 @@ import {
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
@@ -32,6 +31,8 @@ import {
import { dateKeyOfInstant } from "../day/day-url";
import { formatMetricValue } from "./format-value";
import type { CustomMetricEntryDto } from "./types";
import { Segmented } from "@/components/ui/segmented";
import { ChartEmptyState } from "@/components/charts/chart-empty-state";
/**
* v1.25.5 — per-custom-metric trend chart, in the dashboard's visual language.
@@ -121,7 +122,11 @@ export function CustomMetricChart({
focusFor: (index) => {
const point = points[index];
return point
? { label: name, value: formatMetricValue(point.value, decimals), unit }
? {
label: name,
value: formatMetricValue(point.value, decimals, fmt.number),
unit,
}
: null;
},
});
@@ -133,25 +138,21 @@ export function CustomMetricChart({
return (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
aria-pressed={range === r.key}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`customMetrics.chart.range.${r.key}`)}
</Button>
))}
<div className="flex justify-end">
<Segmented
options={RANGE_DAYS.map((r) => ({
value: r.key,
label: t(`customMetrics.chart.range.${r.key}`),
}))}
value={range}
onChange={setRange}
label={t("charts.rangeLabel")}
slot="custom-metric-chart-range"
/>
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("customMetrics.chart.empty")}
</p>
<ChartEmptyState title={t("customMetrics.chart.empty")} height={160} />
) : (
<div>
<div
@@ -187,7 +188,9 @@ export function CustomMetricChart({
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatMetricValue(v, decimals)}
tickFormatter={(v: number) =>
formatMetricValue(v, decimals, fmt.number)
}
/>
{targetLow != null && targetHigh != null ? (
<ReferenceArea
@@ -230,7 +233,7 @@ export function CustomMetricChart({
const rows: RichTooltipRow[] = [
{
name: t("customMetrics.chart.valueLabel"),
value: `${formatMetricValue(point.value, decimals)} ${unit}`,
value: `${formatMetricValue(point.value, decimals, fmt.number)} ${unit}`,
color: primary,
},
];
@@ -272,7 +275,9 @@ export function CustomMetricChart({
}))}
columns={[{ key: "value", label: name }]}
unit={unit}
formatValue={(value) => formatMetricValue(value, decimals)}
formatValue={(value) =>
formatMetricValue(value, decimals, fmt.number)
}
formatDate={(date) => fmt.dateShortSmartCalendar(date)}
bucket="day"
metricLabel={name}
@@ -24,7 +24,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useTableSort } from "@/hooks/use-table-sort";
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { formatMetricValue } from "./format-value";
@@ -64,6 +64,7 @@ export function CustomMetricHistoryList({
decimals: number | null;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const { sortBy, sortDir, toggleSort } = useTableSort({
@@ -242,7 +243,7 @@ export function CustomMetricHistoryList({
{ordered.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-semibold tabular-nums">
{formatMetricValue(r.value, decimals)}
{formatMetricValue(r.value, decimals, fmt.number)}
{unit ? ` ${unit}` : ""}
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
@@ -15,12 +15,13 @@ import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CustomMetricForm } from "./custom-metric-form";
import { formatMetricValue } from "./format-value";
import type { CustomMetricDto, CustomMetricListResponse } from "./types";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.25.5 — inline list of the user's custom metrics, mounted ON the
@@ -31,6 +32,7 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
const fmt = useFormatters();
// v1.36.x — the section is dropped whole inside somebody else's record.
// Defining a metric is not a delegated verb, and every row here links to
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
@@ -98,12 +100,10 @@ export function CustomMetricList() {
onRetry={() => refetch()}
/>
) : metrics.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
<Gauge className="text-muted-foreground size-6" />
<p className="text-muted-foreground text-sm">
{t("customMetrics.emptyDescription")}
</p>
<EmptyState
icon={<Gauge className="size-6" />}
title={t("customMetrics.emptyDescription")}
action={
<Button
size="sm"
onClick={() => setAddOpen(true)}
@@ -111,8 +111,8 @@ export function CustomMetricList() {
>
{t("customMetrics.add")}
</Button>
</CardContent>
</Card>
}
/>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
@@ -133,6 +133,7 @@ export function CustomMetricList() {
{formatMetricValue(
metric.latest.value,
metric.decimals,
fmt.number,
)}
{metric.unit ? ` ${metric.unit}` : ""}
</span>
@@ -17,6 +17,7 @@ import type {
CustomMetricEntryDto,
CustomMetricEntryListResponse,
} from "./types";
import { EmptyState } from "@/components/ui/empty-state";
const ENTRIES_PAGE_SIZE = 200;
@@ -90,9 +91,11 @@ export function CustomMetricValuesList({
if (entries.length === 0) {
return (
<p className="text-muted-foreground py-8 text-center text-sm">
{t("customMetrics.detail.emptyTitle")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("customMetrics.detail.emptyTitle")}
/>
);
}
@@ -1,17 +1,20 @@
/**
* v1.25.5 — format a custom-metric value for display.
* Format a custom-metric value for display.
*
* When the metric defines a fixed number of decimals, honour it; otherwise fall
* back to a compact representation that trims trailing zeros (up to 3 places).
* The digits come from the caller's locale formatter (`useFormatters().number`),
* so a German reader sees "70,5" where an English one sees "70.5".
*/
export function formatMetricValue(
value: number,
decimals: number | null | undefined,
formatNumber: (value: number, fractionDigits?: number) => string,
): string {
if (!Number.isFinite(value)) return "—";
if (decimals != null) {
return value.toFixed(decimals);
return formatNumber(value, decimals);
}
// Compact default: round to at most 3 places and drop trailing zeros.
return String(Math.round(value * 1000) / 1000);
return formatNumber(Math.round(value * 1000) / 1000);
}
+6 -3
View File
@@ -20,6 +20,7 @@ import {
HISTORY_REST_OPACITY,
HISTORY_PERIOD_OPACITY,
} from "./phase-tokens";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.1 — the cycle-length history chart.
@@ -136,9 +137,11 @@ export function CycleHistoryChart({
</CardHeader>
<CardContent className="space-y-4">
{!hasData ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.history.none")}
/>
) : (
<>
{/* Stat chips — avg / variability / regularity classification. */}
+45 -84
View File
@@ -1,13 +1,14 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getReadingTransform,
} from "@/lib/measurements/display-transform";
import { cn } from "@/lib/utils";
import { MetricCrosstab } from "@/components/insights/metric-crosstab";
/**
* v1.15 — CYCLE_PHASE × vital crosstab card.
@@ -68,21 +69,13 @@ const UNIT_KEY: Partial<Record<CyclePhaseCrosstabDisplay, string>> = {
mood: "cycle.insights.crosstab.unitMood",
};
const CONFIDENCE_KEY: Record<CyclePhaseCrosstabConfidence, string> = {
low: "cycle.insights.crosstab.confidenceLow",
medium: "cycle.insights.crosstab.confidenceMedium",
high: "cycle.insights.crosstab.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
function fmt(
value: number,
display: CyclePhaseCrosstabDisplay,
nf: Formatters,
): string {
return display === "steps" ? nf.integer(value) : nf.number(value, 1);
}
/**
@@ -152,6 +145,7 @@ export function CyclePhaseHeadline({
headline: CyclePhaseCrosstabRow | null;
}) {
const { t } = useTranslations();
const nf = useFormatters();
const unitDisplay = useUnitDisplay();
if (!headline) {
return (
@@ -176,7 +170,7 @@ export function CyclePhaseHeadline({
const params = {
metric: metricLabel,
// A phase delta → factor-only conversion (no affine offset).
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display),
delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display, nf),
unit,
dir,
};
@@ -199,75 +193,42 @@ export function CyclePhaseCrosstab({
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
const nf = useFormatters();
const unitDisplay = useUnitDisplay();
if (rows.length === 0) return null;
return (
<div data-slot="cycle-phase-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("cycle.insights.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const {
<MetricCrosstab
slot="cycle-phase-crosstab"
rowSlot="cycle-phase-crosstab-row"
description={t("cycle.insights.crosstab.description")}
rows={rows.map((row) => {
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const {
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase.
return {
key: row.metricKey,
metricKey: row.metricKey,
pairLabel: t("cycle.insights.crosstab.pairLabel", {
metric: metricLabel,
}),
up: row.delta >= 0,
deltaValue: fmt(deltaConv(row.delta), row.display, nf),
unit,
confidence: row.confidence,
detail: t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(absConv(row.lutealAvg), row.display, nf),
follicularAvg: fmt(absConv(row.follicularAvg), row.display, nf),
unit,
abs: absConv,
delta: deltaConv,
} = rowUnits(row.display, unitDisplay, t);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
// observational, so colouring it would imply a verdict the data
// doesn't support. The sign prefix + `data-direction` carry it.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(deltaConv(row.delta), row.display)} ${unit}`;
return (
<li
key={row.metricKey}
className="flex flex-col gap-1.5 py-2"
data-slot="cycle-phase-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<span
className="text-foreground min-w-0 flex-1 truncate"
title={metricLabel}
>
{t("cycle.insights.crosstab.pairLabel", {
metric: metricLabel,
})}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(absConv(row.lutealAvg), row.display),
follicularAvg: fmt(absConv(row.follicularAvg), row.display),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
})}
</p>
</li>
);
})}
</ul>
</div>
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
}),
};
})}
/>
);
}
+6 -6
View File
@@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
import { PHASE_HUE } from "./phase-tokens";
import { useCycleRingCalendar } from "./use-cycle";
import { TileHeader } from "@/components/insights/tile-header";
/**
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
@@ -107,12 +108,11 @@ export function CycleRingTile({ className }: { className?: string }) {
className,
)}
>
<div className="text-foreground flex items-center gap-2">
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{t("cycle.wellnessRing.label")}
</span>
</div>
<TileHeader
icon={Droplets}
title={t("cycle.wellnessRing.label")}
titleClassName="truncate"
/>
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={verdict.dayOfCycle}
@@ -10,6 +10,7 @@ import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { PHASE_HUE } from "./phase-tokens";
import type { CyclePhase } from "./types";
import type { SymptomPhaseRow } from "./use-cycle";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight).
@@ -64,9 +65,11 @@ export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) {
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("cycle.insights.symptomPatternsEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.insights.symptomPatternsEmpty")}
/>
) : (
<ul className="space-y-3" data-slot="cycle-symptom-pattern-list">
{rows.map((row) => {
+8 -8
View File
@@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -73,15 +73,15 @@ const BbtChart = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-card border-border rounded-xl border py-6">
<div className="px-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-44" />
</div>
<div className="mt-6 px-6">
<Skeleton className="mb-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-[210px] w-full rounded-md" />
</div>
</div>
</CardContent>
</Card>
),
},
);
+11 -6
View File
@@ -15,6 +15,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { Formatters } from "@/lib/format-locale";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.15.0 — the predictions panel.
@@ -81,9 +82,11 @@ export function PredictionsPanel({
body={t("cycle.predictions.rawModeBody")}
/>
) : prediction == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.predictions.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.predictions.none")}
/>
) : prediction.stillLearning ? (
<>
<Learning
@@ -215,9 +218,11 @@ function HistoryCard({
</CardHeader>
<CardContent className="space-y-4">
{!stats || stats.avgLengthDays == null ? (
<p className="text-muted-foreground text-sm">
{t("cycle.history.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("cycle.history.none")}
/>
) : (
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Stat
+1 -1
View File
@@ -163,9 +163,9 @@ export function PriorityCard({
<Card
data-slot="priority-card"
data-kind={item.kind}
density="compact"
className={cn(
isMilestone ? "milestone-reached" : "animate-insight-in",
"gap-2 py-3 md:gap-2 md:py-4",
item.status ? STATUS_WASH[item.status] : null,
hue ? "metric-accent" : null,
className,
@@ -11,6 +11,7 @@ import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
import { InlineAction } from "@/components/ui/inline-action";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
@@ -29,12 +30,11 @@ export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
</div>
<Link
href="/admin/encryption"
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
>
{t("dashboard.keyBackupBanner.action")}
</Link>
<InlineAction asChild size="sm" className="min-h-11 font-medium">
<Link href="/admin/encryption">
{t("dashboard.keyBackupBanner.action")}
</Link>
</InlineAction>
</div>
);
}
+7 -3
View File
@@ -44,6 +44,7 @@ import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
import { EmptyState } from "@/components/ui/empty-state";
/**
* One day, as the layer shows it in every shell: a header with the date and
@@ -282,9 +283,12 @@ export function DayView({
) : data === undefined ? (
<DaySkeleton />
) : isEmpty ? (
<p className="text-muted-foreground text-sm" data-slot="day-empty">
{t("day.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("day.empty")}
data-slot="day-empty"
/>
) : (
<>
<DayRunning items={data.running} date={date} />
+2 -2
View File
@@ -200,7 +200,7 @@ export function SinceLastVisit({
// and the reader still sees what could not be loaded.
if (window.isError && rows.length === 0) {
return (
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardHeader>
<TileHeader
size="sm"
@@ -229,7 +229,7 @@ export function SinceLastVisit({
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
return (
<Card className="gap-2 py-3 md:py-4">
<Card density="compact">
<CardHeader>
<TileHeader
size="sm"
@@ -11,7 +11,6 @@ import {
documentCardKeyAction,
documentDateKey,
expandRangeSelection,
formatBytes,
formatMonthLabel,
hasProcessingDocument,
isDocumentProcessing,
@@ -216,15 +215,6 @@ describe("timeline items", () => {
});
});
describe("byte formatting", () => {
it("scales units and keeps one decimal below 10", () => {
expect(formatBytes(0, "en")).toBe("0 B");
expect(formatBytes(512, "en")).toBe("512 B");
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
});
});
describe("upload response contract (§3.2)", () => {
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
const body = JSON.stringify({
@@ -30,6 +30,7 @@ import {
} from "@/lib/validations/inbound-documents";
import type { DocumentDescribeResult } from "./use-document-assist";
import { EmptyState } from "@/components/ui/empty-state";
/**
* The line under a document AI action while its read runs in the background,
@@ -220,9 +221,11 @@ export function AssistSuggestionReview({
) : null}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("documents.assist.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("documents.assist.empty")}
/>
)}
</div>
);
@@ -36,13 +36,13 @@ import { Button } from "@/components/ui/button";
import type { AiRunPhase } from "@/hooks/use-ai-run";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type DocumentContentIndexSourceValue,
type DocumentIndexOutcomeValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
import type {
DocumentContentIndexSourceValue,
DocumentIndexOutcomeValue,
DocumentSuggestionDto,
DocumentSummaryMode,
} from "@/lib/validations/inbound-documents";
import { isAiReadSource } from "@/lib/documents/inbound-constants";
import {
AiRunPhaseNote,
+4 -6
View File
@@ -31,11 +31,8 @@ import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import {
documentCardKeyAction,
formatBytes,
isDocumentProcessing,
} from "./vault-utils";
import { formatBytes } from "@/lib/format/bytes";
import { documentCardKeyAction, isDocumentProcessing } from "./vault-utils";
/** How long the "Ready" chip stays up after a card's index lands. */
const READY_FLASH_MS = 3000;
@@ -220,8 +217,9 @@ export function DocumentCard({
data-slot="document-card"
data-variant={variant}
data-document-id={document.id}
density="compact"
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
"group relative h-full transition-shadow",
"hover:shadow-sm",
highlighted && "ring-primary ring-2",
)}
@@ -75,14 +75,14 @@ import {
useFencedObjectUrl,
} from "@/hooks/use-fenced-object-url";
import { cn } from "@/lib/utils";
import {
INBOUND_DOCUMENT_KINDS,
type DocumentSourceSystemValue,
type DocumentSuggestionDto,
type DocumentSummaryMode,
type InboundDocumentDetailDto,
type InboundDocumentKindValue,
import type {
DocumentSourceSystemValue,
DocumentSuggestionDto,
DocumentSummaryMode,
InboundDocumentDetailDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { INBOUND_DOCUMENT_KINDS } from "@/lib/documents/inbound-constants";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentRecordLinks } from "./document-record-links";
@@ -102,7 +102,7 @@ import {
useDocumentSummary,
useSuggestDetails,
} from "./use-document-assist";
import { formatBytes } from "./vault-utils";
import { formatBytes } from "@/lib/format/bytes";
import { QueryErrorRow } from "@/components/ui/query-error-row";
type PatchInput = {
+20 -147
View File
@@ -2,40 +2,20 @@
/**
* "Verknüpfen" — link existing vault documents to an illness episode from
* the episode's detail page. A `ResponsiveSheet` with a debounced search
* over the vault (title/filename, same recall path as the vault page) and
* one toggle row per document; toggling fires the bulk link/unlink action
* for that single id (idempotent by contract, no-op-success on
* already-in-state rows) and refreshes every document read through the
* `["documents"]` prefix.
*
* Shows one page (50) of matches — the search input is the way to reach an
* older document, mirroring the vault's own recall model.
* the episode's detail page. The shared `DocumentPickerSheet` lists and
* searches the vault; toggling a row fires the bulk link/unlink action for
* that single id (idempotent by contract, no-op-success on already-in-state
* rows) and refreshes every document read through the `["documents"]`
* prefix.
*/
import { useDocumentDate } from "@/components/documents/use-document-date";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type {
DocumentBulkResultDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
import type { DocumentBulkResultDto } from "@/lib/validations/inbound-documents";
import { DocumentPickerSheet } from "./document-picker-sheet";
export function DocumentLinkPicker({
episodeId,
@@ -47,39 +27,8 @@ export function DocumentLinkPicker({
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const documentDate = useDocumentDate();
const queryClient = useQueryClient();
const [searchDraft, setSearchDraft] = useState("");
const [query, setQuery] = useState("");
// 200 ms debounce, matching the vault's search feel.
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const toggle = useMutation({
mutationFn: (input: { documentId: string; linked: boolean }) =>
apiPost<{ results: DocumentBulkResultDto[] }>(
@@ -102,97 +51,21 @@ export function DocumentLinkPicker({
},
});
const documents = list.data?.documents ?? [];
return (
<ResponsiveSheet
<DocumentPickerSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.linkPicker.title")}
description={t("documents.linkPicker.description")}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("documents.linkPicker.searchPlaceholder")}
aria-label={t("documents.linkPicker.searchPlaceholder")}
className="pl-9"
/>
</div>
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<QueryErrorRow
error={list.error}
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-sm">
{t("documents.linkPicker.empty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episodeId,
);
const title =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={linked}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({ documentId: doc.id, linked })
}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
linked && "border-primary/40 bg-primary/5",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<span className="text-muted-foreground block text-xs">
{documentDate(doc)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
searchPlaceholder={t("documents.linkPicker.searchPlaceholder")}
emptyLabel={t("documents.linkPicker.empty")}
isPicked={(doc) =>
doc.conditionLinks.some((l) => l.episodeId === episodeId)
}
isDisabled={() => toggle.isPending}
onToggle={(doc, _title, linked) =>
toggle.mutate({ documentId: doc.id, linked })
}
/>
);
}
@@ -0,0 +1,194 @@
"use client";
/**
* The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on
* phones) with a debounced search over the person's own vault — the same
* recall path as the vault page, one page (50) of matches, the search input
* as the way to an older document — and one toggle row per document.
*
* Two pickers used to carry this whole body as copies: linking documents to
* an illness episode (each toggle writes at once) and choosing documents for
* a share link (the choice is local state, capped, posted with the link).
* What a toggle means stays with each caller; the list, the search, the rows
* and their states live here once.
*/
import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useDocumentDate } from "@/components/documents/use-document-date";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { EmptyState } from "@/components/ui/empty-state";
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 { 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";
interface ListPage {
documents: InboundDocumentDto[];
nextCursor: string | null;
}
export function DocumentPickerSheet({
open,
onOpenChange,
title,
description,
searchPlaceholder,
emptyLabel,
isPicked,
onToggle,
isDisabled,
notice,
footer,
listTestId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
searchPlaceholder: string;
emptyLabel: string;
isPicked: (doc: InboundDocumentDto) => boolean;
/** `title` is the row's display title (title, filename or "untitled"). */
onToggle: (doc: InboundDocumentDto, title: string, picked: boolean) => void;
isDisabled?: (doc: InboundDocumentDto, picked: boolean) => boolean;
/** A status line above the list (the share cap). */
notice?: ReactNode;
footer?: ReactNode;
listTestId?: string;
}) {
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<ReturnType<typeof setTimeout> | 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<ListPage>(`/api/documents/inbound?${sp.toString()}`);
},
});
const documents = list.data?.documents ?? [];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
footer={footer}
>
<div className="space-y-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={searchDraft}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
className="pl-9"
/>
</div>
{notice}
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<QueryErrorRow
error={list.error}
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
) : documents.length === 0 ? (
<EmptyState variant="plain" size="compact" title={emptyLabel} />
) : (
<ul
className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain"
data-testid={listTestId}
>
{documents.map((doc) => {
const picked = isPicked(doc);
const rowTitle =
doc.title ?? doc.filename ?? t("documents.card.untitled");
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
const disabled = isDisabled?.(doc, picked) ?? false;
return (
<li key={doc.id}>
<button
type="button"
aria-pressed={picked}
disabled={disabled}
onClick={() => onToggle(doc, rowTitle, picked)}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
picked && "border-primary/40 bg-primary/5",
disabled && "cursor-not-allowed opacity-50",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{rowTitle}
</span>
<span className="text-muted-foreground block text-xs">
{documentDate(doc)}
</span>
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
picked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</div>
</ResponsiveSheet>
);
}
+8 -8
View File
@@ -61,15 +61,15 @@ import {
} from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
import {
DOCUMENT_BULK_MAX_IDS,
type DocumentBulkAction,
type DocumentBulkResultDto,
type DocumentUsageDto,
type InboundDocumentDetailDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
import type {
DocumentBulkAction,
DocumentBulkResultDto,
DocumentUsageDto,
InboundDocumentDetailDto,
InboundDocumentDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
import { DocumentBulkBar } from "./document-bulk-bar";
import { DocumentDetailSheet } from "./document-detail-sheet";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -50,7 +50,6 @@ import { queryKeys } from "@/lib/query-keys";
import { ImportFromSourceButton } from "../import-from-source-button";
import { mergeIds } from "../merge-ids";
import { nextChoiceIndex } from "../segmented-choice";
import { errorCodeOf, sourceErrorMessage } from "../source-errors";
const PAPERLESS = {
@@ -255,19 +254,6 @@ describe("source error sentences", () => {
});
});
describe("nextChoiceIndex", () => {
it("moves with the arrows, wraps, and jumps with Home and End", () => {
expect(nextChoiceIndex("ArrowRight", 0, 2)).toBe(1);
expect(nextChoiceIndex("ArrowRight", 1, 2)).toBe(0);
expect(nextChoiceIndex("ArrowLeft", 0, 2)).toBe(1);
expect(nextChoiceIndex("ArrowDown", 0, 3)).toBe(1);
expect(nextChoiceIndex("ArrowUp", 0, 3)).toBe(2);
expect(nextChoiceIndex("Home", 2, 3)).toBe(0);
expect(nextChoiceIndex("End", 0, 3)).toBe(2);
expect(nextChoiceIndex("Enter", 0, 3)).toBeNull();
});
});
describe("mergeIds", () => {
it("adds imported ids to a form's selection once, in order", () => {
expect(mergeIds(["a", "b"], ["b", "c", "c"])).toEqual(["a", "b", "c"]);
@@ -75,14 +75,15 @@ import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documen
import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line";
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
import { formatBytes } from "../vault-utils";
import { formatBytes } from "@/lib/format/bytes";
import {
errorCodeOf,
isStoppingError,
sourceErrorMessage,
} from "./source-errors";
import { SegmentedChoice } from "./segmented-choice";
import { Segmented } from "@/components/ui/segmented";
import { systemName } from "./use-document-sources";
import { InlineAction } from "@/components/ui/inline-action";
export interface DocumentPickerLink {
kind: DocumentPickerLinkKind;
@@ -408,13 +409,16 @@ export function DocumentSourcePicker({
>
<div className="space-y-3" data-slot="document-source-picker">
{systems.length > 1 && active ? (
<SegmentedChoice
options={systems}
<Segmented
options={systems.map((system) => ({
value: system,
label: systemName(system),
}))}
value={active}
onChange={switchSystem}
label={t("documents.sourcePicker.sourceLabel")}
disabled={running}
renderOption={systemName}
className="self-start"
/>
) : null}
@@ -485,19 +489,19 @@ export function DocumentSourcePicker({
</div>
</div>
{hasFilters ? (
<button
type="button"
<InlineAction
tone="muted"
onClick={() => {
startOver();
setTag("");
setFrom("");
setTo("");
}}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-8 items-center gap-1 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
className="min-h-8"
>
<X className="size-3.5" aria-hidden />
{t("documents.sourcePicker.clearFilters")}
</button>
</InlineAction>
) : null}
{finished ? (
@@ -1,110 +0,0 @@
"use client";
/**
* A small segmented radio group (Paperless-ngx | Papra) with the keyboard
* contract of a radio group: one tab stop (the checked option), arrow keys
* move and select, Home and End jump to the ends. Shared by the document
* picker's source switch and the settings card's system switch.
*/
import { useRef, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* The option an arrow, Home or End key moves to from `index`, wrapping at the
* ends; null for any other key.
*/
export function nextChoiceIndex(
key: string,
index: number,
count: number,
): number | null {
if (count <= 0) return null;
switch (key) {
case "ArrowRight":
case "ArrowDown":
return (index + 1) % count;
case "ArrowLeft":
case "ArrowUp":
return (index - 1 + count) % count;
case "Home":
return 0;
case "End":
return count - 1;
default:
return null;
}
}
export function SegmentedChoice<T extends string>({
options,
value,
onChange,
label,
disabled = false,
renderOption,
slot,
className,
}: {
options: readonly T[];
value: T;
onChange: (value: T) => void;
/** Accessible name of the group. */
label: string;
disabled?: boolean;
renderOption: (option: T) => ReactNode;
slot?: string;
className?: string;
}) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, i: number) => {
const next = nextChoiceIndex(event.key, i, options.length);
if (next === null) return;
event.preventDefault();
if (disabled) return;
onChange(options[next]);
refs.current[next]?.focus();
};
return (
<div
role="radiogroup"
aria-label={label}
aria-disabled={disabled || undefined}
className={cn(
"bg-muted inline-flex self-start rounded-md p-1",
className,
)}
>
{options.map((option, i) => {
const checked = option === value;
return (
<button
key={option}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
disabled={disabled}
onClick={() => onChange(option)}
onKeyDown={(event) => onKeyDown(event, i)}
data-slot={slot}
className={cn(
"flex min-h-9 items-center gap-1.5 rounded-sm px-3 text-sm font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
checked
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{renderOption(option)}
</button>
);
})}
</div>
);
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { type RefObject } from "react";
import { Progress } from "@/components/ui/progress";
import { useTranslations } from "@/lib/i18n/context";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
import { formatBytes } from "./vault-utils";
import { formatBytes } from "@/lib/format/bytes";
/** Usage fraction above which the quota bar appears. */
const QUOTA_BAR_THRESHOLD = 0.8;
+6 -19
View File
@@ -7,13 +7,15 @@
* and byte formatting. Kept out of the client components so they stay
* trivially unit-testable without a render harness.
*/
import type {
DocumentLinkedProcedureDto,
InboundDocumentDto,
InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import {
DOCUMENT_LIST_DEFAULT_LIMIT,
INBOUND_DOCUMENT_KINDS,
type DocumentLinkedProcedureDto,
type InboundDocumentDto,
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
} from "@/lib/documents/inbound-constants";
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
import { userDayKey } from "@/lib/tz/format";
@@ -302,21 +304,6 @@ export function formatMonthLabel(key: string, locale: string): string {
}).format(new Date(`${key}-15T12:00:00.000Z`));
}
/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */
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;
}
const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1;
return `${new Intl.NumberFormat(locale, {
maximumFractionDigits: digits,
}).format(value)} ${units[unit]}`;
}
/**
* Shift-click range selection over the timeline's visual order. Selects
* every id between the last plainly-toggled anchor and the target
@@ -308,9 +308,7 @@ describe("<VisitsSection> since the last visit", () => {
html.indexOf("since-last-visit-"),
),
);
expect(card).toMatch(
/^<div data-slot="card" class="[^"]*gap-2 py-3 md:py-4/,
);
expect(card).toMatch(/^<div data-slot="card" data-density="compact"/);
expect(card).toContain('data-slot="tile-header"');
expect(card).toMatch(
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
+1 -1
View File
@@ -94,7 +94,7 @@ export function VisitCard({
<Card
data-slot="visit-card"
data-encounter-id={encounter.id}
className="gap-2 py-3 md:py-4"
density="compact"
>
<CardContent className="space-y-2">
<Body onOpen={onOpen} encounter={encounter}>
@@ -27,6 +27,9 @@ import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InlineAction } from "@/components/ui/inline-action";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -126,82 +129,84 @@ export function RecentAchievementsCard() {
}
return (
<div
data-slot="recent-achievements-card"
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
>
<Card data-slot="recent-achievements-card" className="gap-3 md:gap-3">
{/* Canonical tile header (foreground h-5 icon, text-base h2) so the
dashboard card matches its sibling tiles instead of the old muted
h-4 / text-sm row. The "view all" link rides the header's right slot
(§11 inline-action-row). */}
<TileHeader
icon={Trophy}
titleAs="h2"
title={t("achievements.dashboardCard.title")}
right={
<Link
href="/achievements"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("achievements.dashboardCard.viewAll")}
</Link>
}
/>
<CardHeader>
<TileHeader
icon={Trophy}
titleAs="h2"
title={t("achievements.dashboardCard.title")}
right={
<InlineAction asChild tone="muted">
<Link href="/achievements">
{t("achievements.dashboardCard.viewAll")}
</Link>
</InlineAction>
}
/>
</CardHeader>
{showSkeleton ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<Skeleton className="h-7 w-7 shrink-0" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-2.5 w-3/4 rounded" />
</div>
</li>
))}
</ul>
) : recent.length === 0 ? (
<p className="text-muted-foreground py-2 text-sm">
{t("achievements.dashboardCard.empty")}
</p>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
<CardContent>
{showSkeleton ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={achievement.id}
data-slot="recent-achievement-item"
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-primary/15 text-primary rounded-md p-1.5">
<Icon className="h-3.5 w-3.5" />
<Skeleton className="h-7 w-7 shrink-0" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-2.5 w-3/4 rounded" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t(achievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
{achievement.completedAt && (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDate(achievement.completedAt)}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</ul>
) : recent.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("achievements.dashboardCard.empty")}
/>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
<li
key={achievement.id}
data-slot="recent-achievement-item"
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-primary/15 text-primary rounded-md p-1.5">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t(achievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
{achievement.completedAt && (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDate(achievement.completedAt)}
</span>
)}
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
+18 -17
View File
@@ -26,6 +26,7 @@ import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
import type { IllnessDayLogDTO } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
function symptomLabelKey(key: string): string | null {
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
@@ -64,9 +65,11 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) {
})}
</div>
) : (
<p className="text-muted-foreground text-sm">
{t("illness.timeline.noSymptoms")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("illness.timeline.noSymptoms")}
/>
)}
{log.functionalImpact !== null ? (
@@ -134,20 +137,18 @@ export function IllnessDayTimeline({
onRetry={() => void refetch()}
/>
) : dayLogs.length === 0 ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
{t("illness.timeline.empty")}
</p>
{canManageIllness && (
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
)}
</div>
<EmptyState
variant="plain"
size="compact"
title={t("illness.timeline.empty")}
action={
canManageIllness ? (
<Button variant="outline" onClick={onLogDay}>
{t("illness.logDay")}
</Button>
) : undefined
}
/>
) : (
<div className="space-y-4">
{dayLogs.map((log) => (
@@ -21,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useIllnessInsights } from "./use-illness";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
export function IllnessInsightsCard({
windowDays = 365,
@@ -64,9 +65,11 @@ export function IllnessInsightsCard({
onRetry={() => void counts.refetch()}
/>
) : !hasEpisodes ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("illness.insights.none")}
/>
) : (
<div className="space-y-4 text-sm">
<p className="text-foreground">
+6 -4
View File
@@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -141,6 +141,7 @@ export function LogDaySheet({
onsetDate,
}: LogDaySheetProps) {
const { t } = useTranslations();
const fmt = useFormatters();
// The fever is stored in °C and typed in the reader's temperature unit:
// seeded through the transform, inverted on save.
const { toDisplay, fromDisplay, unitFor } = useUnitDisplay();
@@ -185,7 +186,8 @@ export function LogDaySheet({
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(
dto?.feverC != null
? String(toDisplay("BODY_TEMPERATURE", dto.feverC))
? // Seeded in the reader's notation; the parser below takes either mark.
fmt.number(toDisplay("BODY_TEMPERATURE", dto.feverC))
: "",
);
setNote(dto?.note ?? "");
@@ -345,7 +347,7 @@ export function LogDaySheet({
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} {feverUnit}
{fmt.number(feverValue, 1)} {feverUnit}
</span>
) : null
}
@@ -357,7 +359,7 @@ export function LogDaySheet({
<Input
id="illness-fever"
inputMode="decimal"
placeholder={toDisplay("BODY_TEMPERATURE", 37).toFixed(1)}
placeholder={fmt.number(toDisplay("BODY_TEMPERATURE", 37), 1)}
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
@@ -173,7 +173,9 @@ describe("integer-metric precision guard", () => {
const html = render(<InsightsStepsPage />);
expect(html).toContain('data-slot="metric-stat-strip"');
expect(html).not.toMatch(FRACTIONAL_GERMAN);
// Class lists carry grid templates such as `minmax(0,1fr)`; only the
// rendered text is under test.
expect(html.replace(/class="[^"]*"/g, "")).not.toMatch(FRACTIONAL_GERMAN);
// The numbers themselves survive, thousands-grouped and whole.
expect(html).toContain("11.762");
expect(html).toContain("104");
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Moon } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import { MetricCrosstab, type MetricCrosstabRow } from "../metric-crosstab";
function row(overrides: Partial<MetricCrosstabRow> = {}): MetricCrosstabRow {
return {
key: "a",
metricKey: "restingHeartRate",
pairLabel: "Resting heart rate",
up: true,
deltaValue: "2,5",
unit: "bpm",
confidence: "medium",
detail: "62 vs 59.5 bpm",
...overrides,
};
}
function render(rows: MetricCrosstabRow[]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricCrosstab
slot="board"
rowSlot="board-row"
description="How it differs"
rows={rows}
/>
</I18nProvider>,
);
}
describe("<MetricCrosstab>", () => {
it("renders nothing for no rows", () => {
expect(render([])).toBe("");
});
it("prints the delta as given, signed, and never coloured", () => {
const html = render([row()]);
expect(html).toContain('data-slot="board"');
expect(html).toContain('data-slot="board-row"');
expect(html).toContain("+2,5 bpm");
expect(html).toContain('data-direction="up"');
expect(html).not.toMatch(/text-(success|destructive)/);
});
it("leaves a negative delta's own sign alone", () => {
const html = render([row({ up: false, deltaValue: "-1,2" })]);
expect(html).toContain("-1,2 bpm");
expect(html).not.toContain("+-1,2");
expect(html).toContain('data-direction="down"');
});
it("states confidence through the grade chip", () => {
const html = render([row({ confidence: "high" })]);
expect(html).toMatch(/data-slot="score-pill"[^>]*data-tone="success"/);
expect(html).toContain("High confidence");
});
it("draws the row icon when one is given", () => {
expect(render([row({ icon: Moon })])).toContain("<svg");
expect(render([row()])).not.toContain("<svg");
});
it("collapses a dismissed pattern to its notice", () => {
const html = render([row({ patternId: "p1", dismissed: true })]);
expect(html).not.toContain('data-slot="board-row"');
});
});
+1 -1
View File
@@ -101,7 +101,7 @@ export function AiSetupHint({
}
return (
<Card data-slot="ai-setup-hint" className="gap-2 py-3 md:py-4">
<Card data-slot="ai-setup-hint" density="compact">
<CardHeader>
<TileHeader
icon={Sparkles}
@@ -16,6 +16,7 @@ import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
/**
* v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault
@@ -190,9 +191,11 @@ export function AttachmentPicker({
onRetry={() => void list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("insights.coach.attach.pickerEmpty")}
/>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{documents.map((doc) => {
@@ -39,6 +39,7 @@ import type {
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
import { translateOrNull } from "@/lib/i18n/translate-code";
import { InlineAction } from "@/components/ui/inline-action";
/**
* Per-message bubble renderer for the Coach thread, split out of
@@ -775,13 +776,11 @@ function ChatBubbleImpl({
setup instead of inviting an endless retry. */}
{(errorCode === "coach.provider.none" ||
errorCode === "ai.provider.none") && (
<Link
href="/settings/ai"
data-slot="coach-no-provider-cta"
className="text-primary text-xs font-medium underline-offset-4 hover:underline"
>
{t("insights.coach.errorNoProviderAction")}
</Link>
<InlineAction asChild className="font-medium">
<Link href="/settings/ai" data-slot="coach-no-provider-cta">
{t("insights.coach.errorNoProviderAction")}
</Link>
</InlineAction>
)}
{/* v1.18.1 — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
@@ -19,7 +19,7 @@ import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/documents/inbound-constants";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import {
parseUploadResponse,
@@ -38,7 +38,7 @@ import {
type ReasoningLevel,
} from "@/lib/ai/reasoning/levels";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
import { coachReasoningLevel } from "@/lib/ai/coach/prefs-constants";
export type { CoachReasoningState };
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more