fix(dashboard): disambiguate quick-add submenu labels (no more double "Hinzufügen")

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 20:16:57 +02:00
1 parent 786b3951f6
commit 3e45a7ba53
5 files changed
+174 -19

No files matched your search

+20 -2
View File
@@ -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",
+20 -2
View File
@@ -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",
@@ -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);
},
);
});
+7 -4
View File
@@ -373,10 +373,13 @@ export default function DashboardPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* 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`. */}
<DropdownMenuItem
onClick={() => setQuickEntryDialog("measurement")}
>
@@ -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(
<I18nProvider initialLocale="en">
<SidebarNav />
@@ -73,3 +83,42 @@ describe("<SidebarNav> bug-report toggle", () => {
expect(html).not.toContain('href="/bugreport"');
});
});
describe("<SidebarNav> 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 `<AdminShell>` 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}"`);
}
});
});