mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #500 from MBombeck/release/v1.28.43
release/v1.28.43 — visual consistency polish
This commit is contained in:
25 files changed
+717
-544
No files matched your search
@@ -2,6 +2,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.43] — 2026-07-16 — Visual consistency polish
|
||||
|
||||
A pass over small visual inconsistencies the design audit surfaced:
|
||||
|
||||
- The insights header now uses the standard heading weight and spacing scale.
|
||||
- Two dashboard tiles that hand-rolled a dimmer header now use the same tile
|
||||
header as their neighbours, so the cards read as one family.
|
||||
- A few tiles sat a step denser than their siblings; they now match.
|
||||
- Two header icons that used the accent colour are back to the standard.
|
||||
- Several edit dialogs on phone-reachable surfaces now open as the same bottom
|
||||
sheet the rest of the app uses.
|
||||
|
||||
A new lint rule keeps card spacing on the standard scale so this drift can't
|
||||
quietly return.
|
||||
|
||||
## [1.28.42] — 2026-07-16 — Snappier lists, correct mood refresh, safer first sign-up
|
||||
|
||||
Performance and correctness fixes:
|
||||
|
||||
@@ -95,7 +95,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
|
||||
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
|
||||
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
|
||||
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
|
||||
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
|
||||
|
||||
## Tests and commands
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.42
|
||||
version: 1.28.43
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -60,6 +60,32 @@ ruleTester.run("spacing-scale", rule, {
|
||||
code: '<CardHeader className="pb-2" />',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
// The card-shell `5` check is scoped to the bg-card+border pair, so the
|
||||
// justified list-marker inset never trips it (no shell).
|
||||
{
|
||||
code: '<ul className="list-disc pl-5 space-y-1" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// Form-body rhythm without a shell surface is fine.
|
||||
{
|
||||
code: '<form className="space-y-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// A shell already on-scale is fine.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
// bg-card without a border is not treated as a shell.
|
||||
{
|
||||
code: '<div className="bg-card p-5" />',
|
||||
filename: APP_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
@@ -108,5 +134,34 @@ ruleTester.run("spacing-scale", rule, {
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "slotPadding" }],
|
||||
},
|
||||
// Card-shell `5` step: bg-card + border + p-5 on one element.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border p-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
|
||||
{
|
||||
code: '<section className="bg-card rounded-lg border py-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// Split across cn() args — the join still sees the shell + the step-5.
|
||||
{
|
||||
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
// Modifier-prefixed step on a shell.
|
||||
{
|
||||
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
|
||||
filename: APP_FILE,
|
||||
errors: [{ messageId: "cardShellStep5" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6,7 +6,8 @@
|
||||
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
|
||||
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
|
||||
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
|
||||
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
|
||||
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
|
||||
* and no off-scale `5` step on a bg-card shell.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
|
||||
* gap-based Card slots.
|
||||
* @fileoverview ESLint rule — spacing-scale discipline. Two checks:
|
||||
*
|
||||
* The Card primitive is gap-based, not padding-based
|
||||
* (`src/components/ui/card.tsx`): the header→body distance comes from
|
||||
* the PARENT flex gap, never from slot padding. A `pb-*` on
|
||||
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
|
||||
* of the gap (or is a no-op) and inverts its own intent — the drift
|
||||
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
|
||||
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
|
||||
* the sanctioned compact tier), never on a slot.
|
||||
* 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card
|
||||
* slots. The Card primitive is gap-based, not padding-based
|
||||
* (`src/components/ui/card.tsx`): the header→body distance comes from
|
||||
* the PARENT flex gap, never from slot padding. A `pb-*` on
|
||||
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
|
||||
* of the gap (or is a no-op) and inverts its own intent — the drift
|
||||
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
|
||||
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
|
||||
* the sanctioned compact tier), never on a slot.
|
||||
*
|
||||
* Detection: JSX elements named `CardHeader` / `CardContent` (any
|
||||
* member-expression tail, so `UI.CardHeader` matches too) whose
|
||||
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
|
||||
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
|
||||
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
|
||||
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
|
||||
* seen. Non-numeric suffixes (`pb-safe`) never match.
|
||||
* Detection: JSX elements named `CardHeader` / `CardContent` (any
|
||||
* member-expression tail, so `UI.CardHeader` matches too) whose
|
||||
* `className` carries a static `pt-<n>` / `pb-<n>` fragment — including
|
||||
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
|
||||
* Non-numeric suffixes (`pb-safe`) never match.
|
||||
*
|
||||
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
|
||||
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
|
||||
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
|
||||
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
|
||||
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
|
||||
* card on the same viewport. Deliberately NARROW: it fires only when the
|
||||
* same element paints a card surface, so justified `pl-5`/`pl-7`
|
||||
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
|
||||
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
|
||||
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
|
||||
*
|
||||
* Both checks collect strings from the whole `className` subtree, so
|
||||
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
|
||||
*
|
||||
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
|
||||
* gating as the sibling rules. `src/components/ui/card.tsx` itself
|
||||
* composes raw `<div>`s and is untouched by construction.
|
||||
* gating as the sibling rules. `src/components/ui/*` primitives compose the
|
||||
* shells themselves and are untouched by construction (they carry no
|
||||
* `bg-card` + `border` + step-5 combination).
|
||||
*
|
||||
* There is deliberately NO allowlist: no current site has a documented
|
||||
* load-bearing reason for a slot padding override. If one ever appears,
|
||||
* it carries an inline `eslint-disable-next-line` with the reason —
|
||||
* visible in review — rather than a silent registry entry here.
|
||||
* load-bearing reason. If one ever appears it carries an inline
|
||||
* `eslint-disable-next-line` with the reason — visible in review — rather
|
||||
* than a silent registry entry here.
|
||||
*
|
||||
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
|
||||
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
@@ -44,6 +57,19 @@ const SLOT_PADDING_RE =
|
||||
|
||||
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
|
||||
|
||||
// Card-shell heuristic: the element paints a card surface (`bg-card`) AND a
|
||||
// border. `\bborder\b` matches `border`, `border-border`, `border-b`,
|
||||
// `border-dashed` — any border utility qualifies as a shell.
|
||||
const SHELL_BG_RE = /\bbg-card\b/;
|
||||
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
|
||||
|
||||
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
|
||||
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
|
||||
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
|
||||
// half-steps are intentionally OUT of scope (see the header).
|
||||
const SHELL_STEP5_RE =
|
||||
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
@@ -122,12 +148,14 @@ const spacingScaleRule = {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
|
||||
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
slotPadding:
|
||||
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
|
||||
cardShellStep5:
|
||||
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
@@ -138,9 +166,6 @@ const spacingScaleRule = {
|
||||
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
const name = elementName(node);
|
||||
if (!name || !SLOT_NAMES.has(name)) return;
|
||||
|
||||
const classNameAttr = node.attributes.find(
|
||||
(attr) =>
|
||||
attr.type === "JSXAttribute" &&
|
||||
@@ -151,13 +176,33 @@ const spacingScaleRule = {
|
||||
|
||||
const strings = [];
|
||||
collectStrings(classNameAttr.value, strings);
|
||||
for (const { node: strNode, text } of strings) {
|
||||
const m = SLOT_PADDING_RE.exec(text);
|
||||
|
||||
// Check 1 — pt-/pb- slot padding on the gap-based Card slots.
|
||||
const name = elementName(node);
|
||||
if (name && SLOT_NAMES.has(name)) {
|
||||
for (const { node: strNode, text } of strings) {
|
||||
const m = SLOT_PADDING_RE.exec(text);
|
||||
if (m) {
|
||||
context.report({
|
||||
node: strNode,
|
||||
messageId: "slotPadding",
|
||||
data: { match: m[1], element: name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check 2 — off-scale `5` step on a card shell. Join every collected
|
||||
// string so the `bg-card` + `border` pair and the step-5 utility are
|
||||
// seen together even when split across cn() args, then report once.
|
||||
const joined = strings.map((s) => s.text).join(" ");
|
||||
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
|
||||
const m = SHELL_STEP5_RE.exec(joined);
|
||||
if (m) {
|
||||
context.report({
|
||||
node: strNode,
|
||||
messageId: "slotPadding",
|
||||
data: { match: m[1], element: name },
|
||||
node: classNameAttr,
|
||||
messageId: "cardShellStep5",
|
||||
data: { match: m[1] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -66,9 +66,14 @@ const eslintConfig = defineConfig([
|
||||
// token references, not utilities, and stay legal — the light-theme
|
||||
// overrides in globals.css keep them AA on both themes.
|
||||
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
|
||||
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
|
||||
// fights the gap and re-opens the sl-001 drift class. Density lives
|
||||
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
|
||||
// Spacing-scale discipline. Two error-clean checks: pt-/pb- on
|
||||
// CardHeader/CardContent fights the gap-based Card contract (re-opens
|
||||
// the sl-001 drift class), and an off-scale `5` step (p-5/py-5/space-y-5/
|
||||
// gap-5) on a bg-card+border shell reads denser than every sibling
|
||||
// <Card>. The shell check is scoped to the bg-card+border pair so
|
||||
// justified list-marker insets (pl-5/pl-7) and form-body rhythm never
|
||||
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
|
||||
// a swept `p-4 md:p-6` shell. See the rule header.
|
||||
"healthlog/spacing-scale": "error",
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.42",
|
||||
"version": "1.28.43",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.42";
|
||||
/* @sw-version-fallback */ "v1.28.43";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -334,7 +334,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-5">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.points")}
|
||||
</p>
|
||||
@@ -354,7 +354,7 @@ export default function AchievementsPage() {
|
||||
aria-label={t("achievements.points")}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
|
||||
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.unlocked")}
|
||||
</p>
|
||||
@@ -368,7 +368,7 @@ export default function AchievementsPage() {
|
||||
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
|
||||
<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">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.nextGoal")}
|
||||
</p>
|
||||
|
||||
@@ -31,7 +31,10 @@ export default function EnrollMfaPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-foreground inline-flex items-center gap-2 text-lg font-semibold">
|
||||
<ShieldAlert className="text-primary h-5 w-5" aria-hidden="true" />
|
||||
<ShieldAlert
|
||||
className="text-foreground h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("auth.enrollMfa.title")}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -49,4 +49,22 @@ describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
|
||||
expect(html).not.toContain("chart-gradient-medication");
|
||||
expect(html).not.toContain("linearGradient");
|
||||
});
|
||||
|
||||
it("leads with the canonical TileHeader, not the old muted h-4 / text-sm row", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } =
|
||||
await import("../medication-compliance-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Adopts the shared tile-header primitive so the card matches its sibling
|
||||
// HealthChart tiles (foreground h-5 glyph, text-base title).
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
// The old hand-rolled muted h-4 icon row is gone.
|
||||
expect(html).not.toContain("text-muted-foreground h-4 w-4");
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "recharts";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Pill } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -402,8 +403,10 @@ export function MedicationComplianceChart({
|
||||
≥sm: original side-by-side layout. */}
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{displayTitle}</h3>
|
||||
{/* Canonical tile header (foreground h-5 icon, text-base title) so
|
||||
this card reads in the same language as its sibling HealthChart
|
||||
tiles instead of the old muted h-4 / text-sm row. */}
|
||||
<TileHeader icon={Pill} title={displayTitle} />
|
||||
{/* `?` explainer for what the adherence percentage measures —
|
||||
shares the i18n body with the per-card bars. */}
|
||||
<ComplianceInfoTip />
|
||||
|
||||
@@ -106,7 +106,7 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-border bg-card rounded-lg border p-5">
|
||||
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
@@ -345,7 +345,7 @@ export function ClinicianView({
|
||||
|
||||
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
|
||||
{wellness.length > 0 ? (
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-5">
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
|
||||
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
|
||||
@@ -202,6 +202,18 @@ describe("<RecentAchievementsCard>", () => {
|
||||
expect(html).toContain("View all");
|
||||
});
|
||||
|
||||
it("routes the header through the canonical TileHeader (foreground icon, h2)", () => {
|
||||
mockData = { summary: {}, achievements: [], metrics: {} };
|
||||
const html = render();
|
||||
// Adopts the shared tile-header primitive so it matches sibling tiles…
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
// …with the foreground (never muted) glyph and the h2 heading, not the
|
||||
// old muted h-4 / text-sm row.
|
||||
expect(html).toContain("Recent unlocks");
|
||||
expect(html).not.toMatch(/<h2[^>]*text-sm font-semibold/);
|
||||
expect(html).not.toContain("text-muted-foreground h-4 w-4");
|
||||
});
|
||||
|
||||
it("renders up to three most-recent unlocks with title + date", () => {
|
||||
mockData = {
|
||||
summary: {},
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatDate } from "@/lib/format";
|
||||
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";
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
@@ -113,23 +114,23 @@ export function RecentAchievementsCard() {
|
||||
data-slot="recent-achievements-card"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy
|
||||
className="text-muted-foreground h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("achievements.dashboardCard.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/achievements"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("achievements.dashboardCard.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* 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>
|
||||
}
|
||||
/>
|
||||
|
||||
{isPending ? (
|
||||
<ul
|
||||
|
||||
@@ -55,6 +55,27 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).toContain("Good morning");
|
||||
});
|
||||
|
||||
it("renders the H1 at the canonical bold weight and on-scale sizes", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
const greeting = html.match(
|
||||
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
|
||||
);
|
||||
expect(greeting).not.toBeNull();
|
||||
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
|
||||
expect(greeting![1]).toContain("font-bold");
|
||||
expect(greeting![1]).not.toContain("font-semibold");
|
||||
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
|
||||
expect(greeting![1]).not.toContain("text-[28px]");
|
||||
expect(greeting![1]).toContain("sm:text-3xl");
|
||||
});
|
||||
|
||||
it("keeps the hero band and its columns on the spacing scale (no step-5)", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
// The band's own padding + the flex column gaps stay on 4/6, never 5.
|
||||
expect(html).not.toContain("py-5");
|
||||
expect(html).not.toContain("gap-5");
|
||||
});
|
||||
|
||||
it("renders the morning greeting in German", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
|
||||
expect(html).toContain("Guten Morgen");
|
||||
|
||||
@@ -257,7 +257,7 @@ export function HeroStrip({
|
||||
// it the shadow bled through the sticky section nav below
|
||||
// (the nav uses bg-background/80 + backdrop-blur, which the
|
||||
// shadow leaked through).
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-4 sm:px-6 sm:py-6",
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
@@ -290,12 +290,12 @@ export function HeroStrip({
|
||||
from first paint. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
"flex flex-col gap-4",
|
||||
(healthScore || healthScorePending) &&
|
||||
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
@@ -307,7 +307,7 @@ export function HeroStrip({
|
||||
/>
|
||||
<h1
|
||||
data-slot="insights-hero-strip-greeting"
|
||||
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
|
||||
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{greeting}
|
||||
</h1>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function ResearchModeAcknowledgmentDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-primary h-5 w-5 shrink-0" />
|
||||
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
|
||||
{t("medications.researchMode.dialog.title")}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -45,14 +45,7 @@ import { useCallback, useId, useRef, useState } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -208,92 +201,20 @@ export function NaturalLanguageExtractor({
|
||||
const overLimit = charCount > MAX_TEXT_LENGTH;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-busy={busy || undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.scheduling.naturalLanguage.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"medications.scheduling.naturalLanguage.placeholder",
|
||||
)}
|
||||
rows={4}
|
||||
maxLength={MAX_TEXT_LENGTH + 200}
|
||||
disabled={busy}
|
||||
aria-invalid={overLimit || error?.kind === "empty" || undefined}
|
||||
aria-describedby={
|
||||
errorMessage ? `${textareaId}-error` : undefined
|
||||
}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1 text-right text-xs">
|
||||
{charCount} / {MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p
|
||||
id={`${textareaId}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-muted-foreground text-xs font-medium">
|
||||
{t("medications.scheduling.naturalLanguage.examples.label")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.weekly",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.daily",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.rolling",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("medications.scheduling.naturalLanguage.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -322,9 +243,73 @@ export function NaturalLanguageExtractor({
|
||||
? t("medications.scheduling.naturalLanguage.submitBusy")
|
||||
: t("medications.scheduling.naturalLanguage.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"medications.scheduling.naturalLanguage.placeholder",
|
||||
)}
|
||||
rows={4}
|
||||
maxLength={MAX_TEXT_LENGTH + 200}
|
||||
disabled={busy}
|
||||
aria-invalid={overLimit || error?.kind === "empty" || undefined}
|
||||
aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1 text-right text-xs">
|
||||
{charCount} / {MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p
|
||||
id={`${textareaId}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-muted-foreground text-xs font-medium">
|
||||
{t("medications.scheduling.naturalLanguage.examples.label")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.weekly",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t("medications.scheduling.naturalLanguage.examples.daily")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.rolling",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
* (pill packs count too).
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useQuery,
|
||||
@@ -48,14 +48,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -500,6 +493,7 @@ export function AddInventoryDialog({
|
||||
useState<ContainerType>(initialContainerType);
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsed = Number(quantity);
|
||||
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
|
||||
@@ -531,152 +525,147 @@ export function AddInventoryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.addDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-container-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.containerTypeLabel")}
|
||||
</label>
|
||||
<Select
|
||||
value={containerType}
|
||||
onValueChange={(v) => setContainerType(v as ContainerType)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inventory-add-container-type"
|
||||
className="w-full"
|
||||
data-slot="inventory-container-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTAINER_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`medications.detail.bestand.containerType.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
{unitsPerDose > 1 && (
|
||||
<div
|
||||
className="border-border/60 inline-flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
||||
data-slot="inventory-quantity-mode"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "doses"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("doses")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeDoses")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "units"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("units")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeUnits")}
|
||||
</Button>
|
||||
</div>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.addTitle")}
|
||||
description={t("medications.detail.bestand.addDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!quantityValid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={
|
||||
effectiveMode === "doses"
|
||||
? Math.floor(1000 / unitsPerDose)
|
||||
: 1000
|
||||
}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
aria-describedby="inventory-add-quantity-helper"
|
||||
/>
|
||||
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="inventory-quantity-conversion"
|
||||
{t("medications.detail.bestand.addSubmit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-container-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.containerTypeLabel")}
|
||||
</label>
|
||||
<Select
|
||||
value={containerType}
|
||||
onValueChange={(v) => setContainerType(v as ContainerType)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inventory-add-container-type"
|
||||
className="w-full"
|
||||
data-slot="inventory-container-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTAINER_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`medications.detail.bestand.containerType.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
{unitsPerDose > 1 && (
|
||||
<div
|
||||
className="border-border/60 inline-flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
||||
data-slot="inventory-quantity-mode"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "doses"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("doses")}
|
||||
>
|
||||
{effectiveMode === "doses"
|
||||
? t("medications.detail.bestand.quantityInUnits", { units })
|
||||
: // A unit count below one dose must not read "≈ 0 doses" —
|
||||
// it is simply less than one dose.
|
||||
Math.floor(parsed / unitsPerDose) === 0
|
||||
? t("medications.detail.bestand.quantityUnderOneDose")
|
||||
: t("medications.detail.bestand.quantityInDoses", {
|
||||
doses: Math.floor(parsed / unitsPerDose),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{t("medications.detail.bestand.quantityModeDoses")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "units"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("units")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeUnits")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={
|
||||
effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000
|
||||
}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
aria-describedby="inventory-add-quantity-helper"
|
||||
/>
|
||||
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="inventory-quantity-conversion"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityHelper")}
|
||||
{effectiveMode === "doses"
|
||||
? t("medications.detail.bestand.quantityInUnits", { units })
|
||||
: // A unit count below one dose must not read "≈ 0 doses" —
|
||||
// it is simply less than one dose.
|
||||
Math.floor(parsed / unitsPerDose) === 0
|
||||
? t("medications.detail.bestand.quantityUnderOneDose")
|
||||
: t("medications.detail.bestand.quantityInDoses", {
|
||||
doses: Math.floor(parsed / unitsPerDose),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-expiry"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addExpiryLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-add-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!quantityValid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.addSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="inventory-add-expiry" className="text-sm font-medium">
|
||||
{t("medications.detail.bestand.addExpiryLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-add-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -703,6 +692,7 @@ function AdjustInventoryDialog({
|
||||
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsed = Number(value);
|
||||
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
|
||||
@@ -733,68 +723,65 @@ function AdjustInventoryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.bestand.adjustTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.adjustDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-adjust-remaining"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal ?? undefined}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-describedby="inventory-adjust-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-adjust-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total:
|
||||
item.unitsTotal ?? t("medications.detail.bestand.unknown"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!valid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.adjustSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.adjustTitle")}
|
||||
description={t("medications.detail.bestand.adjustDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!valid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.adjustSubmit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-adjust-remaining"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal ?? undefined}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-describedby="inventory-adjust-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-adjust-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total: item.unitsTotal ?? t("medications.detail.bestand.unknown"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -823,6 +810,7 @@ function PackagingDialog({
|
||||
dosesPerUnit === null ? "" : String(dosesPerUnit),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsedPerDose = Number(perDoseValue);
|
||||
const perDoseValid =
|
||||
@@ -864,94 +852,92 @@ function PackagingDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.bestand.packagingTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.packagingDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-units-per-dose"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-units-per-dose"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={perDoseValue}
|
||||
onChange={(e) => setPerDoseValue(e.target.value)}
|
||||
aria-describedby="packaging-units-per-dose-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-units-per-dose-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-default-pack"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-default-pack"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
step={1}
|
||||
autoComplete="off"
|
||||
value={packValue}
|
||||
onChange={(e) => setPackValue(e.target.value)}
|
||||
aria-describedby="packaging-default-pack-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-default-pack-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!perDoseValid || !packValid || busy}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.packagingTitle")}
|
||||
description={t("medications.detail.bestand.packagingDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!perDoseValid || !packValid || busy}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-units-per-dose"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-units-per-dose"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={perDoseValue}
|
||||
onChange={(e) => setPerDoseValue(e.target.value)}
|
||||
aria-describedby="packaging-units-per-dose-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-units-per-dose-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-default-pack"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-default-pack"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
step={1}
|
||||
autoComplete="off"
|
||||
value={packValue}
|
||||
onChange={(e) => setPackValue(e.target.value)}
|
||||
aria-describedby="packaging-default-pack-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-default-pack-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackHelper")}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function DoneScreen() {
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-5 text-left"
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
<span
|
||||
|
||||
@@ -5,13 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -506,7 +500,7 @@ export function AccountSection() {
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
security control, so it sits beside Research Mode + the danger zone. */}
|
||||
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPasswordDialogOpen(open);
|
||||
@@ -518,81 +512,73 @@ export function AccountSection() {
|
||||
setPasswordMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.changePasswordDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("settings.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SheetHeader } from "../sheet";
|
||||
|
||||
/**
|
||||
* The raw `SheetHeader` reserves a trailing gutter for the sheet's absolute
|
||||
* close-X the way `DialogHeader` (pr-9) does — sized to the sheet close
|
||||
* button (`right-4`, min-w-9…11) so a long raw-`<Sheet>` title never slides
|
||||
* under the X. Latent hardening (every current consumer passes its own
|
||||
* header/close), but it forecloses the Documents-class overlap at the
|
||||
* primitive. Consumers that render no close-X drop the reserve via className,
|
||||
* and tailwind-merge keeps the later value.
|
||||
*/
|
||||
describe("<SheetHeader>", () => {
|
||||
it("reserves the trailing close-X gutter (pr-12) by default", () => {
|
||||
const html = renderToStaticMarkup(<SheetHeader>Title</SheetHeader>);
|
||||
expect(html).toContain('data-slot="sheet-header"');
|
||||
expect(html).toContain("pr-12");
|
||||
});
|
||||
|
||||
it("lets a consumer className override the reserve", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetHeader className="p-3">Title</SheetHeader>,
|
||||
);
|
||||
// A later padding utility from the consumer wins under tailwind-merge.
|
||||
expect(html).toContain("p-3");
|
||||
expect(html).not.toMatch(/class="[^"]*\bpr-12\b/);
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,14 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
// Reserve the trailing gutter for the absolute close-X the way
|
||||
// `DialogHeader` (pr-9) does, sized to the sheet's own close button
|
||||
// (`top-3 right-4`, min-w-9…11) so a long raw-`<Sheet>` title never
|
||||
// slides under the X — the Documents close-X overlap class, foreclosed
|
||||
// at the primitive. Consumers that render `showCloseButton={false}`
|
||||
// (or their own close affordance) drop it back with `pr-4` via
|
||||
// `className` — tailwind-merge keeps the later value.
|
||||
className={cn("flex flex-col gap-1.5 p-4 pr-12", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user