mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'fix/v1438-ui' into release/v1.43.8
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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"',
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user