From 3e45a7ba53e232a365635d48b40b937efc386ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 20:16:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20disambiguate=20quick-add=20su?= =?UTF-8?q?bmenu=20labels=20(no=20more=20double=20"Hinzuf=C3=BCgen")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard "Add" dropdown rendered two menu items whose visible text both resolved to bare nouns ("Messung" / "Stimmungseintrag" in DE, "Measurement" / "Mood entry" in EN). Combined with the trigger label ("Hinzufügen" / "Add"), screen-reader users effectively heard the same verb three times in a row with no signal which row did what. Update both translations to self-contained verb-phrases: - DE: "Messung erfassen" / "Stimmung erfassen" - EN: "Log measurement" / "Log mood" Add `src/app/__tests__/quick-add-labels.test.ts` to lock the contract: both labels must be non-empty, distinct from each other, and distinct from `common.add` (the trigger). The test loads `messages/{en,de}.json` directly so any future translator who collapses the labels back to "Hinzufügen" or "Add" gets a red bar before merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 22 +++++- messages/en.json | 22 +++++- src/app/__tests__/quick-add-labels.test.ts | 67 +++++++++++++++++ src/app/page.tsx | 11 +-- .../layout/__tests__/sidebar-nav.test.tsx | 71 ++++++++++++++++--- 5 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 src/app/__tests__/quick-add-labels.test.ts diff --git a/messages/de.json b/messages/de.json index d5027e0b7..8e8f6b280 100644 --- a/messages/de.json +++ b/messages/de.json @@ -216,8 +216,8 @@ "trendDown": "fallend", "trendStable": "stabil", "quickEntry": "Schnelleingabe", - "quickAddMeasurement": "Messung", - "quickAddMood": "Stimmungseintrag", + "quickAddMeasurement": "Messung erfassen", + "quickAddMood": "Stimmung erfassen", "addMeasurement": "Messwert hinzufügen", "compliance7d": "Einnahmetreue (7 Tage)", "complianceGood": "Gut", @@ -1468,6 +1468,24 @@ "overview": "Übersicht", "sectionsNav": "Admin-Bereiche" }, + "overview": { + "welcomeTitle": "Willkommen, {name}", + "welcomeSubtitle": "Du bist als Administrator angemeldet. Wähle einen Bereich in der Seitenleiste, um HealthLog zu verwalten.", + "snapshotTitle": "System-Snapshot", + "snapshotVersion": "Version", + "snapshotDatabase": "Datenbank", + "snapshotWorker": "Worker", + "snapshotStarted": "Gestartet", + "snapshotBuildSha": "Build-SHA", + "snapshotBuiltAt": "Gebaut am", + "snapshotLoadError": "System-Snapshot konnte nicht geladen werden.", + "snapshotLoading": "Snapshot wird geladen...", + "auditTitle": "Letzte Aktivitäten", + "auditEmpty": "Keine aktuellen Audit-Einträge.", + "auditLoadError": "Letzte Aktivitäten konnten nicht geladen werden.", + "auditLoading": "Aktivitäten werden geladen...", + "auditViewAll": "Alle anzeigen" + }, "section": { "system-status": { "title": "Systemstatus", diff --git a/messages/en.json b/messages/en.json index 76c44a975..b9283d46e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -216,8 +216,8 @@ "trendDown": "falling", "trendStable": "stable", "quickEntry": "Quick entry", - "quickAddMeasurement": "Measurement", - "quickAddMood": "Mood entry", + "quickAddMeasurement": "Log measurement", + "quickAddMood": "Log mood", "addMeasurement": "Add measurement", "compliance7d": "Compliance (7 days)", "complianceGood": "Good", @@ -1468,6 +1468,24 @@ "overview": "Overview", "sectionsNav": "Admin sections" }, + "overview": { + "welcomeTitle": "Welcome, {name}", + "welcomeSubtitle": "You are signed in as administrator. Pick a section from the sidebar to manage HealthLog.", + "snapshotTitle": "System snapshot", + "snapshotVersion": "Version", + "snapshotDatabase": "Database", + "snapshotWorker": "Worker", + "snapshotStarted": "Started", + "snapshotBuildSha": "Build SHA", + "snapshotBuiltAt": "Built at", + "snapshotLoadError": "Failed to load system snapshot.", + "snapshotLoading": "Loading snapshot...", + "auditTitle": "Recent activity", + "auditEmpty": "No recent audit entries.", + "auditLoadError": "Failed to load recent activity.", + "auditLoading": "Loading activity...", + "auditViewAll": "View all" + }, "section": { "system-status": { "title": "System Status", diff --git a/src/app/__tests__/quick-add-labels.test.ts b/src/app/__tests__/quick-add-labels.test.ts new file mode 100644 index 000000000..e48f55600 --- /dev/null +++ b/src/app/__tests__/quick-add-labels.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * v1.4.15 phase-A3 fix #1 — the dashboard "Hinzufügen" / "Add" dropdown used + * to render two menu items whose visible labels both resolved to the bare + * verb ("Hinzufügen" in DE, "Add" in EN). The dropdown trigger ALSO says + * "Hinzufügen", so a screen-reader user heard "Hinzufügen, Hinzufügen, + * Hinzufügen" with no way to tell the entries apart. + * + * The fix is to give each entry a self-contained noun-phrase label + * ("Messung erfassen", "Stimmung erfassen") that doesn't collide with the + * trigger or the other entry. This guard locks that contract in: if anyone + * later edits one of the two values to match the other (or to match + * `common.add`), the test fails before the regression ships. + * + * Lives under `src/app/__tests__` because the consumer is the dashboard + * page (`src/app/page.tsx`); the keys themselves are namespaced under + * `dashboard.*` in both messages files. + */ + +const ROOT = join(__dirname, "../../.."); +const EN_PATH = join(ROOT, "messages/en.json"); +const DE_PATH = join(ROOT, "messages/de.json"); + +interface Messages { + common: { add: string }; + dashboard: { + quickAddMeasurement: string; + quickAddMood: string; + }; +} + +function load(path: string): Messages { + return JSON.parse(readFileSync(path, "utf8")) as Messages; +} + +describe("dashboard quick-add submenu labels", () => { + it.each([ + ["en", EN_PATH], + ["de", DE_PATH], + ])( + "%s: measurement and mood entries have distinct labels and neither equals the trigger label", + (_locale, path) => { + const messages = load(path); + + const measurement = messages.dashboard.quickAddMeasurement; + const mood = messages.dashboard.quickAddMood; + const trigger = messages.common.add; + + // Non-empty + expect(measurement.trim().length).toBeGreaterThan(0); + expect(mood.trim().length).toBeGreaterThan(0); + + // Distinct from each other — the icon is decorative (aria-hidden), + // so the only thing distinguishing the two rows is the visible text. + expect(measurement).not.toBe(mood); + + // Distinct from the trigger label. The trigger sits ABOVE the menu + // and announces itself first; if a menu item then repeats the same + // word, screen-readers / users get no signal which row does what. + expect(measurement).not.toBe(trigger); + expect(mood).not.toBe(trigger); + }, + ); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 101f56196..668c38de5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -373,10 +373,13 @@ export default function DashboardPage() { - {/* v1.5 phase-5: the menu items used to share the literal label - "Add" with the dialog titles, so screen-reader users heard - two indistinguishable "Add" rows in the same dropdown. The - trigger already says "Add" — these label what's being added. */} + {/* Menu items must each carry a self-contained verb-phrase + ("Log measurement", "Log mood") — the trigger above already + says "Add", and the icon is `aria-hidden`, so the visible + text is the only thing distinguishing the rows. v1.4.15 + phase-A3 fix #1 hardened this with a unit guard at + `src/app/__tests__/quick-add-labels.test.ts` — both labels + must differ from each other AND from `common.add`. */} setQuickEntryDialog("measurement")} > diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 9e21cb051..b53d456f8 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -10,17 +10,20 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), })); -// Auth + theme + logout — the user section is rendered inside the -// sidebar and otherwise pulls in TanStack Query + the auth endpoint. +// Each test mutates `mockUserRef.value.role` before rendering so we can +// exercise both regular-user and admin layouts from the same suite. +const mockUserRef = { + value: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "USER" as "USER" | "ADMIN", + gravatarUrl: null, + }, +}; vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - gravatarUrl: null, - }, + user: mockUserRef.value, isAuthenticated: true, isLoading: false, refetch: vi.fn(), @@ -36,7 +39,7 @@ vi.mock("@/components/providers", () => ({ }), })); -// The flag-under-test. Each test mutates `mockSettingsRef.value` before +// The bug-report flag — each test mutates `mockSettingsRef.value` before // rendering so the sidebar reads the desired state. const mockSettingsRef = { value: { bugReportEnabled: true } }; vi.mock("@/components/app-settings-provider", () => ({ @@ -45,13 +48,20 @@ vi.mock("@/components/app-settings-provider", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { SidebarNav } from "../sidebar-nav"; +import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; function render({ pathname = "/", bugReportEnabled = true, -}: { pathname?: string; bugReportEnabled?: boolean } = {}) { + role = "USER" as "USER" | "ADMIN", +}: { + pathname?: string; + bugReportEnabled?: boolean; + role?: "USER" | "ADMIN"; +} = {}) { mockPathnameRef.value = pathname; mockSettingsRef.value = { bugReportEnabled }; + mockUserRef.value = { ...mockUserRef.value, role }; return renderToStaticMarkup( @@ -73,3 +83,42 @@ describe(" bug-report toggle", () => { expect(html).not.toContain('href="/bugreport"'); }); }); + +describe(" admin sub-items context-awareness", () => { + it("does not render admin entries at all for a regular user", () => { + const html = render({ role: "USER", pathname: "/admin/system-status" }); + expect(html).not.toContain('href="/admin"'); + for (const section of ADMIN_SECTIONS) { + expect(html).not.toContain(`href="/admin/${section.slug}"`); + } + }); + + it("for an admin off /admin/* shows a single Admin link without sub-items", () => { + const html = render({ role: "ADMIN", pathname: "/" }); + expect(html).toContain('href="/admin"'); + // No sub-route links — sidebar collapses into a single Admin entry. + for (const section of ADMIN_SECTIONS) { + expect(html).not.toContain(`href="/admin/${section.slug}"`); + } + }); + + it("for an admin on /admin/* expands the section sub-list", () => { + const html = render({ role: "ADMIN", pathname: "/admin/system-status" }); + expect(html).toContain('href="/admin"'); + // All section sub-items must render — the global sidebar mirrors + // the in-shell sidebar so users can jump between sections. + for (const section of ADMIN_SECTIONS) { + expect(html).toContain(`href="/admin/${section.slug}"`); + } + }); + + it("expands sub-items on the /admin overview page (mirrors in-shell sidebar)", () => { + // The in-shell `` sidebar already lists every section on + // `/admin`. The global sidebar must mirror that so the navigation + // experience is identical between the two viewports. + const html = render({ role: "ADMIN", pathname: "/admin" }); + for (const section of ADMIN_SECTIONS) { + expect(html).toContain(`href="/admin/${section.slug}"`); + } + }); +});