mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
786b3951f6
commit
3e45a7ba53
5 files changed
+174
-19
No files matched your search
+20
-2
@@ -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
@@ -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
@@ -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}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user