mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
ux: i18n leaks, focus rings, tap-target floor, admin chip-scroll, empty-state CTA vocabulary
Eight hotfix-ready UX/a11y findings from the v1.4.34.3 UX audit
land as one batch.
i18n leaks (3 primitives):
- Dialog + Sheet close-X carried hardcoded `<span sr-only>Close</span>`
so a German user heard 'Close, button' on every modal. Both lift
`useTranslations()` and read `common.close`.
- ChartSkeleton's sr-only loading announcement was English-only.
The component is already a client island; reads `charts.loadingLabel`
from each locale now. New key shipped in DE / EN / FR / ES / IT / PL.
- Notifications-section breadcrumb `<nav aria-label="Breadcrumb">`
was hardcoded. Routes through `t("nav.breadcrumb")` with a new
key in each locale.
Focus rings (mobile + tablet nav):
- Bottom-nav primary links + the More overflow trigger relied on a
`text-muted-foreground` → `text-primary` colour transition only;
keyboard / switch-control users saw no focus indicator at all.
Added `focus-visible:ring-ring/50 focus-visible:ring-2
focus-visible:outline-none focus-visible:ring-offset-2` matching
the v1.4.33 sidebar treatment.
- Mobile top-bar trigger had `focus:outline-none` without a
`focus-visible` replacement. Same treatment applied.
Tap-target floor (WCAG 2.5.5):
- Input / NativeSelect / Select / DateTimeInput were locked to
h-10 (40 px) on every viewport. Switched to `h-11 sm:h-10` so
mobile clears the 44 px floor while desktop keeps the 40 px norm
that the Button primitive already converged on in v1.4.33.
Admin chip-strip auto-scroll-into-view:
- The admin shell shipped 13 chips but lacked the
`scrollIntoView({inline: 'center'})` effect the settings shell
got in v1.4.33. Admins tapping a chip past the right edge landed
on the new page with the strip scrolled to the left and the
active chip off-screen. Copied the settings-shell pattern verbatim.
List-page subtitles on mobile:
- Measurements / Mood / Medications pages hid their descriptive
subtitle on `<sm` with `hidden sm:block`. Comparable apps keep
the subtitle on mobile because it doubles as contextual help.
Dropped the hide rule; the H1 still anchors the visual.
Insights empty-state CTA vocabulary:
- `ALLOWED_ADD_TYPES` on `/measurements` accepted tokens (`GLUCOSE`,
`TEMPERATURE`, `HEART_RATE`, `BMI`) that didn't match the
`MEASUREMENT_TYPES` enum the form uses. Several `/insights/*`
empty-state CTAs emitted those mismatched tokens and the dialog
silently failed to open. Replaced with a single source of truth
derived from `MEASUREMENT_TYPES` plus a normalisation map for
legacy tokens. Added BODY_TEMPERATURE to the form's enum row.
New unit test walks every `?add=` token emitted under
`src/app/insights/**` and asserts it resolves to a real form type.
This commit is contained in:
1 parent
6885703505
commit
1768b1d971
22 files changed
+235
-39
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
MEASUREMENT_FORM_TYPE_VALUES,
|
||||
ADD_TOKEN_ALIASES,
|
||||
resolveAddToken,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
|
||||
/**
|
||||
* F-1 contract — every `?add=<token>` the Insights surfaces ship must
|
||||
* either land on a real `<MeasurementForm>` row or be an explicit
|
||||
* legacy alias mapped to one. Without this guard the empty-state CTA
|
||||
* silently drops the deep link and the dialog never opens — the
|
||||
* symptom that surfaced as "the Körpertemperatur CTA does nothing".
|
||||
*/
|
||||
describe("measurements ?add= deep link", () => {
|
||||
const repoRoot = join(__dirname, "..", "..", "..", "..");
|
||||
const insightsRoot = join(repoRoot, "src", "app", "insights");
|
||||
const insightsComponentsRoot = join(repoRoot, "src", "components", "insights");
|
||||
|
||||
// Captures both `href="/measurements?add=FOO"` literals and the
|
||||
// dynamic `?add=${prop}` template the shared HealthKit metric page
|
||||
// uses. The latter is covered by walking `emptyStateCtaType="…"` in
|
||||
// every `/insights/<slug>/page.tsx`.
|
||||
const literalAddPattern = /\/measurements\?add=([A-Z_]+)/g;
|
||||
const ctaTypePattern = /emptyStateCtaType=\{?["']([A-Z_]+)["']/g;
|
||||
|
||||
function collectFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
out.push(...collectFiles(full));
|
||||
} else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = [
|
||||
...collectFiles(insightsRoot),
|
||||
...collectFiles(insightsComponentsRoot),
|
||||
];
|
||||
|
||||
const emittedTokens = new Set<string>();
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(literalAddPattern)) {
|
||||
emittedTokens.add(match[1]);
|
||||
}
|
||||
for (const match of source.matchAll(ctaTypePattern)) {
|
||||
emittedTokens.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
it("walks at least one `?add=` token under src/app/insights", () => {
|
||||
// Sanity guard so the regex collector doesn't silently zero out.
|
||||
expect(emittedTokens.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every emitted `?add=` token resolves to a real form type", () => {
|
||||
const unresolved: string[] = [];
|
||||
for (const token of emittedTokens) {
|
||||
if (resolveAddToken(token) === null) unresolved.push(token);
|
||||
}
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("legacy aliases each map to a real form type", () => {
|
||||
for (const [legacy, canonical] of Object.entries(ADD_TOKEN_ALIASES)) {
|
||||
expect(MEASUREMENT_FORM_TYPE_VALUES).toContain(canonical);
|
||||
expect(resolveAddToken(legacy)).toBe(canonical);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves unknown tokens to null without throwing", () => {
|
||||
expect(resolveAddToken("NOT_A_TYPE")).toBeNull();
|
||||
expect(resolveAddToken(null)).toBeNull();
|
||||
expect(resolveAddToken(undefined)).toBeNull();
|
||||
expect(resolveAddToken("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import {
|
||||
MeasurementForm,
|
||||
resolveAddToken,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -10,23 +13,6 @@ import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
// v1.4.27 MB6 — the insights empty-state CTAs link here with a
|
||||
// `?add=<TYPE>` query param to auto-open the add-measurement dialog
|
||||
// with the right default type. Keep the allow-list aligned with
|
||||
// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value
|
||||
// is dropped silently so a stale link can never trap the user on a
|
||||
// broken form.
|
||||
const ALLOWED_ADD_TYPES = new Set([
|
||||
"BLOOD_PRESSURE",
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BMI",
|
||||
"GLUCOSE",
|
||||
"TEMPERATURE",
|
||||
"BODY_FAT",
|
||||
"HEART_RATE",
|
||||
]);
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -52,8 +38,13 @@ export default function MeasurementsPage() {
|
||||
|
||||
if (addParam && addParam !== consumedAddParam) {
|
||||
setConsumedAddParam(addParam);
|
||||
if (ALLOWED_ADD_TYPES.has(addParam)) {
|
||||
setDefaultType(addParam);
|
||||
// v1.4.34 IW-G — `resolveAddToken` is the single source of truth
|
||||
// for legacy aliases (`GLUCOSE`, `TEMPERATURE`, …) plus the
|
||||
// canonical form values. Unknown tokens still drop silently so a
|
||||
// stale link cannot trap the user on a broken form.
|
||||
const resolved = resolveAddToken(addParam);
|
||||
if (resolved) {
|
||||
setDefaultType(resolved);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
// Drop the query string so the back-button leaves the user on
|
||||
@@ -82,7 +73,10 @@ export default function MeasurementsPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("measurements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
|
||||
the H1 isn't an unframed label. `text-xs sm:text-sm`
|
||||
preserves the desktop hierarchy. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("measurements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function MedicationsPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function MoodPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("mood.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("mood.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user