diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index 37f668ae9..ced193b97 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -15,6 +15,7 @@ ## Recent bug/story notes (2026-07) +- [Issue #1814 — i18n parity guard + usePhotos](issue-1814-i18n-parity-guard.md) (2026-07-07) — new `client/src/i18n/i18n.parity.test.ts`: generalized 14-namespace en/de key parity + 28-file duplicate-key raw-text scanner (canonical pattern going forward); `usePhotos.test.ts` reworked to assert real translateApiError/t() output; parallel translator/frontend edits landed before my first test run — all-green on first try was real, verified via git diff - [Issue #1813 — formatter consolidation](issue-1813-formatter-consolidation.md) (2026-07-07) — "shadow render" LocaleProvider-wrap pattern; CODE_BUG found (BudgetSourcesPage.tsx `formatPercent` ReferenceError, real TS2552, latent/untested by pre-existing tests); client-project ts-jest does NOT catch this TS error class at test-run time (inline tsconfig ≠ full program) — always cross-check with `npx tsc -p client/tsconfig.json --noEmit`; SignatureCapture.tsx lines 113-127 are genuinely unreachable dead code (pre-existing, out of scope); canvas/jsdom mocking recipe for SignatureCapture.test.tsx (new file, closed test-parity gap) - [issue-1812-i18n-sweep-json-dup-keys.md](issue-1812-i18n-sweep-json-dup-keys.md) (2026-07-07) — 3 CODE_BUGs found: diary.json duplicate top-level keys (filterBar/page/detailPage) silently wiped ~25 pre-existing translations via JSON.parse last-key-wins; GanttChart.tsx crashed (ReferenceError: t is not defined — useTranslation never added despite spec); 2 wrong-key-path typos. Detection recipe + patterns documented. - [issue-1811-fastify-error-code-mapping.md](issue-1811-fastify-error-code-mapping.md) (2026-07-07) — FST\_\* → ErrorCode mapping test pattern (realistic body-limit/malformed-JSON routes + synthetic fallback-path errors); TOCTOU branch in davTokens.ts /profile route not independently testable, coverage-check-only per spec diff --git a/.claude/agent-memory/qa-integration-tester/issue-1813-formatter-consolidation.md b/.claude/agent-memory/qa-integration-tester/issue-1813-formatter-consolidation.md index 3830363ee..b0052ddd5 100644 --- a/.claude/agent-memory/qa-integration-tester/issue-1813-formatter-consolidation.md +++ b/.claude/agent-memory/qa-integration-tester/issue-1813-formatter-consolidation.md @@ -24,6 +24,7 @@ function render(ui: ReactElement, options?: Parameters[1]) { return rtlRender({ui}, options); } ``` + Works even when `ui` already contains `` — LocaleProvider just wraps whatever is passed. For files using the shared `renderWithRouter` from `client/src/test/testUtils.tsx`, do NOT edit that shared util (16 consumers) — instead define a local `renderWithRouter` override combining ``. @@ -44,7 +45,7 @@ to the formatter assertion. `formatPercent: _formatPercent` (line 95) but the diff introduced two call sites (lines 183–184) that reference the bare identifier `formatPercent`, which doesn't exist in that scope. Confirmed via `npx tsc -p client/tsconfig.json --noEmit` (TS2552 "Cannot find name 'formatPercent'. Did you mean -'_formatPercent'?") **and** at runtime: a test that hovers a `.segment` div (`fireEvent.mouseEnter`) to trigger +'\_formatPercent'?") **and** at runtime: a test that hovers a `.segment` div (`fireEvent.mouseEnter`) to trigger the tooltip render throws `ReferenceError: formatPercent is not defined`. The bug was latent/undetected by the 108 pre-existing tests because none of them ever hover a segment. Test written and left in place (documents correct expected behavior); it will pass once the fix lands (rename destructure to `formatPercent`, or use @@ -52,7 +53,7 @@ correct expected behavior); it will pass once the fix lands (rename destructure ### ts-jest gap (client project only): does NOT catch this class of TS error at test-run time -`npx jest BudgetSourcesPage.test.tsx` passed 108/108 *before* my hover test was added, despite the file having a +`npx jest BudgetSourcesPage.test.tsx` passed 108/108 _before_ my hover test was added, despite the file having a real TS2552 compile error the whole time. The client project's `jest.config.ts` ts-jest transform uses an **inline** `tsconfig` object (`module: 'ESNext', moduleResolution: 'bundler', jsx: 'react-jsx', ...`), not a path to `client/tsconfig.json` — this produces isolatedModules-like behavior that does NOT do full-program @@ -61,7 +62,7 @@ type-checking, so scope-resolution errors like "cannot find name" silently pass actually executes). **Always run `npx tsc -p client/tsconfig.json --noEmit`** (or the equivalent server/shared tsconfig) as a supplementary check when reviewing frontend-developer changes — `npx jest --coverage` alone will not surface this bug class unless a test happens to exercise the exact broken line. CI's separate `npm run -typecheck` step *does* catch it (uses the real tsconfig.json), so this is CI-safe but Jest-blind locally. +typecheck` step _does_ catch it (uses the real tsconfig.json), so this is CI-safe but Jest-blind locally. ### Discovered dead code (out of scope, not touched): SignatureCapture.tsx lines 113–127 @@ -75,7 +76,7 @@ Issue #1813's diff — flagged as a discovered follow-up, not fixed (QA does not ### File-size mock pattern for locale-aware Intl mocks (BackupsPage.test.tsx) -When a component's `formatters.js` module is *fully* `jest.unstable_mockModule`'d (not a partial mock), and you +When a component's `formatters.js` module is _fully_ `jest.unstable_mockModule`'d (not a partial mock), and you need a locale-switchable mock function (e.g. `formatFileSize`), declare a mutable `let mockFormattersLocale = 'en-US';` **above** the `jest.unstable_mockModule(...)` call and reference it inside the factory closure — reset it in `beforeEach`. The factory function isn't invoked until the dynamic `import()` inside `beforeEach` runs, by @@ -86,12 +87,13 @@ looking suspicious. The initial sweep missed 3 suites that mock `formatters.js`/`LocaleContext.js` per-file via `jest.unstable_mockModule` rather than through the shared `render` wrapper or `testUtils.tsx` — CI failed on: + - `GanttChart.test.tsx`: `GanttHeader` (rendered by `GanttChart`) started statically importing the new `formatWeekdayMonthDay` as a bare top-level named export (NOT via `useFormatters()`), so the file's existing `jest.unstable_mockModule('../../lib/formatters.js', ...)` block needed a new top-level key added alongside `formatCurrency`/`formatDate`/`formatPercent` — outside the `useFormatters` mock's return object. - `DocumentBrowser.test.tsx` and `DiaryEntryEditPage.test.tsx`: a descendant started calling `useLocale()` - directly (not via `formatters.js`), so these files needed a *new* `jest.unstable_mockModule('.../LocaleContext.js', ...)` + directly (not via `formatters.js`), so these files needed a _new_ `jest.unstable_mockModule('.../LocaleContext.js', ...)` block added (mirroring the standard shape: `locale`/`resolvedLocale: 'en'`, `currency: 'EUR'`, `setLocale`/`syncWithServer: jest.fn()`, passthrough `LocaleProvider`) — `formatters.js` itself stayed unmocked in both. @@ -99,7 +101,7 @@ The initial sweep missed 3 suites that mock `formatters.js`/`LocaleContext.js` p **Lesson**: when a component gains `useFormatters()` or a new static formatter/locale import, the consumer-blast-radius sweep must include every ancestor→descendant render chain, not just direct importers — search for `jest.unstable_mockModule('.../formatters.js'` AND `'.../LocaleContext.js'` across the whole client -tree, and check whether the *specific new symbol* is exported at the mock's top level vs. nested inside the +tree, and check whether the _specific new symbol_ is exported at the mock's top level vs. nested inside the `useFormatters` return, since a component can consume the same module both ways in different files. ### Round 2 CI fix (same PR #1845): one more grandparent consumer, closure walked to fixpoint @@ -112,9 +114,10 @@ standard `LocaleContext.js` mock block (mirrors `DocumentBrowser.test.tsx`'s blo `../../contexts/LocaleContext.js` since both live two levels under `client/src/`). **Corrected process — blast-radius checks must be a transitive closure, not one grep pass:** + 1. `grep -rl client/src --include='*.tsx' --include='*.ts' | grep -v '\.test\.' | grep -v - '/\.tsx?$'` to get direct importers. -2. Re-run step 1 on *each new importer found*, accumulating a visited set, until a pass finds zero new files +'/\.tsx?$'` to get direct importers. +2. Re-run step 1 on _each new importer found_, accumulating a visited set, until a pass finds zero new files (fixpoint). Two hops was not enough this time — went 3 deep (`DocumentCard` → `DocumentBrowser` → `InvoicePaperlessPickerModal`; separately `LinkedDocumentsSection` → `HouseholdItemDetailPage`/etc. → nothing further). @@ -124,7 +127,7 @@ standard `LocaleContext.js` mock block (mirrors `DocumentBrowser.test.tsx`'s blo 4. For every file in the closure that has a `.test.tsx`, either confirm existing LocaleContext/formatters handling (mock block, or a real unmocked `LocaleProvider` ancestor like `App.test.tsx` uses) or actually run it — do not assume "probably fine" from reading the mock list only. Batching ~5 suites per `npx jest - --maxWorkers=1` invocation from repo root works fine and stays within the 2-minute default timeout. +--maxWorkers=1` invocation from repo root works fine and stays within the 2-minute default timeout. 5. Root-level aggregators (`App.tsx`/`App.test.tsx`) are worth including explicitly since they're the terminal node of nearly every closure in this codebase — check them once at the end rather than per-branch. diff --git a/.claude/agent-memory/qa-integration-tester/issue-1814-i18n-parity-guard.md b/.claude/agent-memory/qa-integration-tester/issue-1814-i18n-parity-guard.md new file mode 100644 index 000000000..62d39bdef --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/issue-1814-i18n-parity-guard.md @@ -0,0 +1,49 @@ +--- +name: issue-1814-i18n-parity-guard +description: New generalized i18n.parity.test.ts (14-namespace en/de key parity + 28-file duplicate-key guard) and usePhotos.test.ts translateApiError rework — patterns and gotchas +metadata: + type: project +--- + +## What shipped (2026-07-07, issue #1814) + +- New file `client/src/i18n/i18n.parity.test.ts`: generalizes the single-namespace pattern from + `errorTranslation.test.ts` (`'German error JSON has the same keys as English'`) to all 14 + registered namespaces via a hand-maintained `NAMESPACES` array, cross-checked against + `fs.readdirSync('client/src/i18n/en').filter(f => f.endsWith('.json')).length` so an + added/removed namespace file fails the test until `NAMESPACES` is updated. Also added a + raw-text recursive-descent duplicate-JSON-key scanner (`findDuplicateKeys`) run over all 28 + locale files (`en/*.json` + `de/*.json`) via `fs.readFileSync` — deliberately NOT `JSON.parse` + since parsing silently resolves duplicates via last-key-wins (see also + [issue-1812-i18n-sweep-json-dup-keys.md](issue-1812-i18n-sweep-json-dup-keys.md), same failure + class). The parser tracks a real scope stack (not a shallow brace counter) specifically to avoid + false positives on `{{interpolation}}` placeholders adjacent to nested-object boundaries — ship + the 3 inline self-tests (genuine dupe / different-scope same-name / interpolation-adjacent) to + prove the parser itself before trusting it against real files. +- `import.meta.url` + `fileURLToPath` works fine in this project's client Jest ESM setup for + locating `en`/`de` dirs relative to the test file (no prior precedent in the codebase for this + pattern in a test file, but it worked cleanly with + `NODE_OPTIONS=--experimental-vm-modules npx jest ...`). +- `usePhotos.test.ts`: reworked the 4 hardcoded-string error assertions to import + `enErrors`/`enPhotoViewer` and assert against the real translated values (matches the + `translateApiError()`/`t()` pattern `usePhotos.ts` already implements), added a 5th test for the + unknown-code humanized fallback (`SOME_UNKNOWN_CODE` → `'Some Unknown Code'`). 100% statement + coverage on `usePhotos.ts` maintained (38/38 tests pass); no changes needed to hoisted mock setup. + +## Coordination note — parallel frontend/translator edits landed mid-session + +Per the spec, `usePhotos.ts` + `en/*.json` (frontend-developer) and `de/*.json` +(translator) were being edited in parallel while I wrote tests. By the time I ran the new +`i18n.parity.test.ts`, **both had already landed** — all 46 tests passed on the first real run +(no known-pending-translator failures to report). Do not assume a first-try all-green result on a +brand new parity/dupe-guard test means the test is too weak — verify by checking +`git status --short client/src/i18n/de/` for what actually changed before concluding the guard is +ineffective. In this case `git diff --stat` confirmed real deletions (duplicate `title`/`delete`/ +`edit`+`view` keys) and additions (`networkError`/`unexpectedError` translations) had landed. + +## Reusable pattern + +The `NAMESPACES` array + `flatten()` parity check + `findDuplicateKeys()` raw-text scanner in +`client/src/i18n/i18n.parity.test.ts` is now the canonical generalized i18n guard for this repo — +future new namespaces just need one line added to `NAMESPACES` (test fails loudly otherwise via +the file-count cross-check) and don't need a new bespoke parity test. diff --git a/.claude/agent-memory/ux-designer/MEMORY.md b/.claude/agent-memory/ux-designer/MEMORY.md index 1526ee4b5..96db96a65 100644 --- a/.claude/agent-memory/ux-designer/MEMORY.md +++ b/.claude/agent-memory/ux-designer/MEMORY.md @@ -20,4 +20,6 @@ - `role="status"` already implies `aria-live="polite"` — never add both attributes. - `BadgeVariantMap` entries need both `label` and `className` — a missing `className` makes the variant's style dead on arrival. - New component reuse audit: check `Badge`, `SearchPicker`, `Modal`, `Skeleton`, `EmptyState`, `FormError` before proposing anything new; see [component-patterns.md](component-patterns.md) for established section-card/badge/picker conventions. -- Cannot `gh pr review --request-changes` on your own PRs — use `--comment` and note it in the body; if `--body-file` posting fails silently, fall back to `gh api repos/.../issues/{N}/comments`. +- Cannot `gh pr review --request-changes` **or** `--approve` on your own PRs (GraphQL rejects both, not just request-changes) — post via `gh api repos/.../issues/{N}/comments` with an explicit "Verdict: APPROVED/CHANGES_REQUIRED" line in the body instead. +- `Intl.NumberFormat` enables thousands-grouping by default; `toFixed()`/manual string-building never did. When a PR swaps a `toFixed()` call for a locale formatter, script-verify the _old vs new_ output isn't just per-locale-correct but also identical for large values (≥1000) — grouping separators are an easy-to-miss 3rd English-output regression beyond whatever the PR explicitly discloses. See PR #1845 in [pr-review-findings.md](pr-review-findings.md). +- `formatDateForAria`-style aria-labels that interpolate localized weekday/month into a hardcoded English sentence order (`"{weekday}, {month} {day}, {year}"`) render grammatically odd in German (`"Dienstag, Februar 24, 2026"` vs correct `"Dienstag, 24. Februar 2026"`) — this is an accepted, deferred, non-blocking gap across the app, not a new bug to flag each time. diff --git a/.claude/agent-memory/ux-designer/pr-review-findings.md b/.claude/agent-memory/ux-designer/pr-review-findings.md index f9e6d744d..944fc49fb 100644 --- a/.claude/agent-memory/ux-designer/pr-review-findings.md +++ b/.claude/agent-memory/ux-designer/pr-review-findings.md @@ -43,6 +43,19 @@ Reviewed against the issue #1804 spec (see [feature-spec-history.md](feature-spe - Only finding: informational nit on `

` hint as a direct child of `

` (HTML content-model violation) — traced back to my own spec's JSX, not an implementation deviation; non-blocking. +## PR #1845 — Locale-aware display formatters (formatDate/formatPercent/formatFileSize/formatHours/formatWeekday\*/formatDateTimeWithZone) (APPROVED via gh api comment, own PR) + +Consolidated ~10 previously hardcoded-`'en-US'` or ad-hoc `toFixed()`/manual-string call sites in `client/src/lib/formatters.ts` behind app-locale-aware formatters (Gantt tooltips/header, calendar aria-labels, document dates, diary work-duration hours, signature timestamps, file sizes, percents). Script-verified de-DE `Intl` output for every new formatter against the PR's own test assertions (all matched) rather than eyeballing — see [pr-review-findings.md](pr-review-findings.md) process notes and the #1844 lesson below. + +Findings, both informational/non-blocking: + +- `formatPercent`'s `Intl.NumberFormat` grouping means English output for values ≥1000 gained a thousands separator (`"1,500%"` vs old `"1500%"`) that the PR's disclosed "byte-identical except two cases" claim didn't call out — a 3rd, undisclosed English-output edge case. Only affects an sr-only announcement, only at ≥10x-over-budget values. General lesson recorded in MEMORY.md: always check `toFixed()`→`Intl.NumberFormat()` swaps for grouping-separator diffs at large values, not just decimal-separator correctness. +- `formatDateForAria` (pre-existing pattern, not introduced by this PR but newly localized here) keeps English sentence order in German output ("Dienstag, Februar 24, 2026" instead of "Dienstag, 24. Februar 2026") — accepted deferred grammar gap per task brief. + +Verified mechanism of the disclosed "document dates fixed off-by-one-day UTC bug": old code did `new Date(isoString).toLocaleDateString('en-US')` (parses as UTC instant, converts to browser-local zone — can roll the calendar day for non-UTC users); new `formatDate` slices `YYYY-MM-DD` and builds a local-midnight `Date` directly, avoiding the UTC→local conversion. Real fix, not a regression. + +Confirmed `gh pr review --approve` (not just `--request-changes`) also fails with "Can not approve your own pull request" on own PRs — same workaround as request-changes: `gh api repos/.../issues/{N}/comments`. + ## Process notes - Cannot `--request-changes` on your own PRs — use `--comment` instead, and note this in the review body diff --git a/client/src/components/photos/PhotoAnnotator/PhotoAnnotator.module.css b/client/src/components/photos/PhotoAnnotator/PhotoAnnotator.module.css index 6c5a0f891..59627ad58 100644 --- a/client/src/components/photos/PhotoAnnotator/PhotoAnnotator.module.css +++ b/client/src/components/photos/PhotoAnnotator/PhotoAnnotator.module.css @@ -67,11 +67,6 @@ min-height: 44px; } -.resetButton { - composes: btnSecondary from '../../../styles/shared.module.css'; - min-height: 44px; -} - /* Shared iconButton sub-pattern (dark surface — used by PhotoViewer info bar buttons) */ .iconButton { display: flex; @@ -108,18 +103,6 @@ border-width: 0; } -/* Modal buttons (for reset confirmation) */ -.modalActions { - display: flex; - gap: var(--spacing-3); - justify-content: flex-end; - margin-top: var(--spacing-4); -} - -.confirmButton { - composes: btnPrimary from '../../../styles/shared.module.css'; -} - .liveRegion { position: absolute; width: 1px; diff --git a/client/src/hooks/usePhotos.test.ts b/client/src/hooks/usePhotos.test.ts index 97bd1a205..337b86e38 100644 --- a/client/src/hooks/usePhotos.test.ts +++ b/client/src/hooks/usePhotos.test.ts @@ -1,6 +1,8 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { jest } from '@jest/globals'; import type { Photo } from '@cornerstone/shared'; +import enErrors from '../i18n/en/errors.json'; +import enPhotoViewer from '../i18n/en/photoViewer.json'; // ─── Hoisted mocks (must precede dynamic import) ─────────────────────────────── @@ -159,26 +161,37 @@ describe('usePhotos', () => { // ─── Error handling ──────────────────────────────────────────────────────── - it('sets error from ApiClientError message and clears loading', async () => { + it('sets error from ApiClientError code via translateApiError', async () => { mockGetPhotosForEntity.mockRejectedValueOnce( - new MockApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }), + new MockApiClientError(500, { code: 'INTERNAL_ERROR', message: 'ignored server message' }), ); const { result } = renderHook(() => usePhotos('diary_entry', 'entry-1')); - await waitFor(() => expect(result.current.error).toBe('Server error')); + await waitFor(() => expect(result.current.error).toBe(enErrors.INTERNAL_ERROR)); expect(result.current.loading).toBe(false); expect(result.current.photos).toEqual([]); }); - it('uses fallback message when ApiClientError has no message', async () => { + it('translates a known ApiClientError code even when message is absent', async () => { mockGetPhotosForEntity.mockRejectedValueOnce( new MockApiClientError(401, { code: 'UNAUTHORIZED' }), ); const { result } = renderHook(() => usePhotos('diary_entry', 'entry-1')); - await waitFor(() => expect(result.current.error).toBe('Failed to load photos.')); + await waitFor(() => expect(result.current.error).toBe(enErrors.UNAUTHORIZED)); + expect(result.current.loading).toBe(false); + }); + + it('humanizes an unknown ApiClientError code as a fallback', async () => { + mockGetPhotosForEntity.mockRejectedValueOnce( + new MockApiClientError(500, { code: 'SOME_UNKNOWN_CODE' }), + ); + + const { result } = renderHook(() => usePhotos('diary_entry', 'entry-1')); + + await waitFor(() => expect(result.current.error).toBe('Some Unknown Code')); expect(result.current.loading).toBe(false); }); @@ -187,9 +200,7 @@ describe('usePhotos', () => { const { result } = renderHook(() => usePhotos('diary_entry', 'entry-1')); - await waitFor(() => - expect(result.current.error).toBe('Network error: Unable to connect to the server.'), - ); + await waitFor(() => expect(result.current.error).toBe(enPhotoViewer.networkError)); expect(result.current.loading).toBe(false); }); @@ -198,7 +209,7 @@ describe('usePhotos', () => { const { result } = renderHook(() => usePhotos('diary_entry', 'entry-1')); - await waitFor(() => expect(result.current.error).toBe('An unexpected error occurred.')); + await waitFor(() => expect(result.current.error).toBe(enPhotoViewer.unexpectedError)); expect(result.current.loading).toBe(false); }); diff --git a/client/src/hooks/usePhotos.ts b/client/src/hooks/usePhotos.ts index afa81bc46..8b1ecc27b 100644 --- a/client/src/hooks/usePhotos.ts +++ b/client/src/hooks/usePhotos.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import type { Photo } from '@cornerstone/shared'; import { getPhotosForEntity, @@ -7,6 +8,7 @@ import { deletePhoto as deletePhotoApi, } from '../lib/photoApi.js'; import { ApiClientError, NetworkError } from '../lib/apiClient.js'; +import { translateApiError } from '../lib/errorTranslation.js'; export interface UsePhotosResult { photos: Photo[]; @@ -25,6 +27,8 @@ export interface UsePhotosResult { } export function usePhotos(entityType: string, entityId: string): UsePhotosResult { + const { t } = useTranslation('photoViewer'); + const { t: tErrors } = useTranslation('errors'); const [photos, setPhotos] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -52,11 +56,11 @@ export function usePhotos(entityType: string, entityId: string): UsePhotosResult } catch (err) { if (!cancelled) { if (err instanceof ApiClientError) { - setError(err.error.message ?? 'Failed to load photos.'); + setError(translateApiError(err.error.code, tErrors)); } else if (err instanceof NetworkError) { - setError('Network error: Unable to connect to the server.'); + setError(t('networkError')); } else { - setError('An unexpected error occurred.'); + setError(t('unexpectedError')); } } } finally { @@ -70,7 +74,7 @@ export function usePhotos(entityType: string, entityId: string): UsePhotosResult return () => { cancelled = true; }; - }, [entityType, entityId, fetchCount]); + }, [entityType, entityId, fetchCount, t, tErrors]); const uploadPhoto = useCallback( async (file: File, caption?: string, onProgress?: (percent: number) => void) => { diff --git a/client/src/i18n/de/diary.json b/client/src/i18n/de/diary.json index 8ddcfc9f5..89d8110e0 100644 --- a/client/src/i18n/de/diary.json +++ b/client/src/i18n/de/diary.json @@ -56,7 +56,6 @@ "subsidy_status": "Förderprogrammstatus" }, "create": { - "title": "Tagebucheintrag erstellen", "backLink": "← Tagebuch", "entryDate": "Einzugsdatum", "entryDateRequired": "Einzugsdatum ist erforderlich", @@ -91,7 +90,6 @@ "deleteErrorMessage": "Fehler beim Löschen des Tagebucheintrags. Bitte versuchen Sie es erneut." }, "edit": { - "title": "Tagebucheintrag bearbeiten", "backLink": "← Zurück zum Tagebuch", "entryDate": "Einzugsdatum", "entryDateRequired": "Einzugsdatum ist erforderlich", diff --git a/client/src/i18n/de/householdItems.json b/client/src/i18n/de/householdItems.json index 43a52bfce..a0cd9498f 100644 --- a/client/src/i18n/de/householdItems.json +++ b/client/src/i18n/de/householdItems.json @@ -257,7 +257,6 @@ "backButton": "← Zurück zu Haushaltsartikeln", "toSchedule": "Zum Zeitplan", "edit": "Bearbeiten", - "delete": "Löschen", "details": { "title": "Details", "description": "Beschreibung", diff --git a/client/src/i18n/de/photoViewer.json b/client/src/i18n/de/photoViewer.json index f9b506112..e7777d918 100644 --- a/client/src/i18n/de/photoViewer.json +++ b/client/src/i18n/de/photoViewer.json @@ -25,6 +25,8 @@ "saveButton": "Speichern", "saving": "Wird gespeichert...", "saveError": "Metadaten konnten nicht gespeichert werden", + "networkError": "Netzwerkfehler: Verbindung zum Server nicht möglich.", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten.", "delete": "Foto löschen", "deleteConfirmTitle": "Foto löschen?", "deleteConfirmBody": "Dieses Foto wird dauerhaft entfernt. Diese Aktion kann nicht rückgängig gemacht werden.", diff --git a/client/src/i18n/de/schedule.json b/client/src/i18n/de/schedule.json index e654e8ae8..a395c28b7 100644 --- a/client/src/i18n/de/schedule.json +++ b/client/src/i18n/de/schedule.json @@ -227,25 +227,6 @@ "notFound": "Meilenstein nicht gefunden", "notFoundMessage": "Der gesuchte Meilenstein existiert nicht.", "backLink": "Zurück zu Meilensteinen", - "edit": "Bearbeiten", - "view": { - "targetDate": "Zieldatum", - "projectedDate": "Voraussichtliches Datum", - "description": "Beschreibung", - "completedAt": "Abgeschlossen am", - "linkedItems": "Verknüpfte Artikel", - "workItem": "Arbeitspaket", - "householdItem": "Haushaltsartikel", - "noItems": "Keine Artikel verknüpft", - "dependentItems": "Abhängige Arbeitspakete", - "noDependentItems": "Keine abhängigen Arbeitspakete", - "searchPlaceholder": "Arbeitspakete oder Haushaltsartikel zum Verknüpfen durchsuchen...", - "depSearchPlaceholder": "Arbeitspakete zum Hinzufügen als abhängig durchsuchen...", - "noMatches": "Keine Artikel entsprechen Ihrer Suche", - "noDepMatches": "Keine Arbeitspakete entsprechen Ihrer Suche", - "deleteButton": "Meilenstein löschen", - "editButton": "Bearbeiten" - }, "edit": { "title": "Meilenstein bearbeiten", "form": { diff --git a/client/src/i18n/en/diary.json b/client/src/i18n/en/diary.json index c8ce0cec5..8b96b553d 100644 --- a/client/src/i18n/en/diary.json +++ b/client/src/i18n/en/diary.json @@ -56,7 +56,6 @@ "subsidy_status": "Subsidy Status" }, "create": { - "title": "Create Diary Entry", "backLink": "← Diary", "entryDate": "Entry Date", "entryDateRequired": "Entry date is required", @@ -91,7 +90,6 @@ "deleteErrorMessage": "Failed to delete diary entry. Please try again." }, "edit": { - "title": "Edit Diary Entry", "backLink": "← Back to Diary", "entryDate": "Entry Date", "entryDateRequired": "Entry date is required", diff --git a/client/src/i18n/en/householdItems.json b/client/src/i18n/en/householdItems.json index 1368b9d7d..4cc274524 100644 --- a/client/src/i18n/en/householdItems.json +++ b/client/src/i18n/en/householdItems.json @@ -257,7 +257,6 @@ "backButton": "← Back to Household Items", "toSchedule": "To Schedule", "edit": "Edit", - "delete": "Delete", "details": { "title": "Details", "description": "Description", diff --git a/client/src/i18n/en/photoAnnotator.json b/client/src/i18n/en/photoAnnotator.json index 70daaa001..0b2b2a49d 100644 --- a/client/src/i18n/en/photoAnnotator.json +++ b/client/src/i18n/en/photoAnnotator.json @@ -51,10 +51,5 @@ "fontSizeXlarge": "Extra large", "editText": "Edit text", "editMeasurement": "Edit measurement label", - "measurementPlaceholder": "Enter distance", - "reset": "Reset to original", - "resetTitle": "Reset to original photo?", - "resetBody": "This will discard all current annotations and start from the original photo. Your previously saved annotations will be preserved.", - "resetConfirm": "Reset", - "resetComplete": "Reset to original photo complete" + "measurementPlaceholder": "Enter distance" } diff --git a/client/src/i18n/en/photoViewer.json b/client/src/i18n/en/photoViewer.json index e028ed9e9..6402726c4 100644 --- a/client/src/i18n/en/photoViewer.json +++ b/client/src/i18n/en/photoViewer.json @@ -25,6 +25,8 @@ "saveButton": "Save", "saving": "Saving...", "saveError": "Failed to save metadata", + "networkError": "Network error: Unable to connect to the server.", + "unexpectedError": "An unexpected error occurred.", "delete": "Delete photo", "deleteConfirmTitle": "Delete this photo?", "deleteConfirmBody": "This photo will be permanently removed. This action cannot be undone.", diff --git a/client/src/i18n/en/schedule.json b/client/src/i18n/en/schedule.json index 8cc991f66..a046baaf5 100644 --- a/client/src/i18n/en/schedule.json +++ b/client/src/i18n/en/schedule.json @@ -226,26 +226,7 @@ "notFoundMessage": "The milestone you're looking for doesn't exist.", "backLink": "Back to Milestones", "error": "Failed to load milestone. Please try again.", - "edit": "Edit", "editButton": "Edit", - "view": { - "targetDate": "Target Date", - "projectedDate": "Projected Date", - "description": "Description", - "completedAt": "Completed At", - "linkedItems": "Linked Items", - "workItem": "Work Item", - "householdItem": "Household Item", - "noItems": "No items linked", - "dependentItems": "Dependent Work Items", - "noDependentItems": "No dependent work items", - "searchPlaceholder": "Search work items or household items to link...", - "depSearchPlaceholder": "Search work items to add as dependent...", - "noMatches": "No items match your search", - "noDepMatches": "No work items match your search", - "deleteButton": "Delete Milestone", - "editButton": "Edit" - }, "edit": { "title": "Edit Milestone", "form": { diff --git a/client/src/i18n/i18n.parity.test.ts b/client/src/i18n/i18n.parity.test.ts new file mode 100644 index 000000000..189b1ca52 --- /dev/null +++ b/client/src/i18n/i18n.parity.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +// Import all English namespace files +import enCommon from './en/common.json'; +import enErrors from './en/errors.json'; +import enAuth from './en/auth.json'; +import enDashboard from './en/dashboard.json'; +import enWorkItems from './en/workItems.json'; +import enHouseholdItems from './en/householdItems.json'; +import enBudget from './en/budget.json'; +import enSchedule from './en/schedule.json'; +import enDiary from './en/diary.json'; +import enDocuments from './en/documents.json'; +import enSettings from './en/settings.json'; +import enAreas from './en/areas.json'; +import enPhotoViewer from './en/photoViewer.json'; +import enPhotoAnnotator from './en/photoAnnotator.json'; + +// Import all German namespace files +import deCommon from './de/common.json'; +import deErrors from './de/errors.json'; +import deAuth from './de/auth.json'; +import deDashboard from './de/dashboard.json'; +import deWorkItems from './de/workItems.json'; +import deHouseholdItems from './de/householdItems.json'; +import deBudget from './de/budget.json'; +import deSchedule from './de/schedule.json'; +import deDiary from './de/diary.json'; +import deDocuments from './de/documents.json'; +import deSettings from './de/settings.json'; +import deAreas from './de/areas.json'; +import dePhotoViewer from './de/photoViewer.json'; +import dePhotoAnnotator from './de/photoAnnotator.json'; + +// ─── Namespace registry ───────────────────────────────────────────────────── +// +// This list must be kept in sync with the `resources`/`ns` arrays in +// `client/src/i18n/index.ts`. Scenario 6 below cross-checks its length +// against the actual file count in `client/src/i18n/en/` so that adding or +// removing a namespace file without updating this array fails loudly instead +// of silently skipping parity/duplicate-key coverage for the new namespace. +const NAMESPACES: { name: string; en: Record; de: Record }[] = [ + { name: 'common', en: enCommon, de: deCommon }, + { name: 'errors', en: enErrors, de: deErrors }, + { name: 'auth', en: enAuth, de: deAuth }, + { name: 'dashboard', en: enDashboard, de: deDashboard }, + { name: 'workItems', en: enWorkItems, de: deWorkItems }, + { name: 'householdItems', en: enHouseholdItems, de: deHouseholdItems }, + { name: 'budget', en: enBudget, de: deBudget }, + { name: 'schedule', en: enSchedule, de: deSchedule }, + { name: 'diary', en: enDiary, de: deDiary }, + { name: 'documents', en: enDocuments, de: deDocuments }, + { name: 'settings', en: enSettings, de: deSettings }, + { name: 'areas', en: enAreas, de: deAreas }, + { name: 'photoViewer', en: enPhotoViewer, de: dePhotoViewer }, + { name: 'photoAnnotator', en: enPhotoAnnotator, de: dePhotoAnnotator }, +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Recursively flattens a nested translation object into dot-delimited leaf keys. */ +function flatten(obj: Record, prefix = ''): string[] { + let keys: string[] = []; + for (const k of Object.keys(obj)) { + const full = prefix ? `${prefix}.${k}` : k; + const v = obj[k]; + if (v && typeof v === 'object' && !Array.isArray(v)) { + keys = keys.concat(flatten(v as Record, full)); + } else { + keys.push(full); + } + } + return keys; +} + +/** + * Recursive-descent duplicate-key scanner over raw JSON text. + * + * This is intentionally NOT a naive brace-counter: a shallow single-pass + * brace-counting approach produces false positives on files containing + * `{{interpolation}}` placeholders adjacent to nested-object boundaries + * (discovered while authoring this test — a first-draft brace counter + * flagged `entryTypes`/`status`/`actions`/`description`/`loadError` as + * duplicates when they were not). This implementation tracks a proper + * scope stack via recursive `parseValue()`/`parseObject()` descent so only + * genuine same-scope duplicate keys are reported. + */ +function findDuplicateKeys(text: string): string[] { + let i = 0; + const n = text.length; + const scopeStack: Set[] = []; + const dups: string[] = []; + + function skipWs() { + while (i < n && /\s/.test(text[i]!)) i++; + } + function parseString(): string { + i++; // opening quote + let s = ''; + while (i < n && text[i] !== '"') { + if (text[i] === '\\') { + s += text[i]! + text[i + 1]!; + i += 2; + } else { + s += text[i]; + i++; + } + } + i++; // closing quote + return s; + } + function parseValue(): void { + skipWs(); + const c = text[i]; + if (c === '{') { + i++; + scopeStack.push(new Set()); + skipWs(); + while (text[i] !== '}') { + skipWs(); + const key = parseString(); + skipWs(); + i++; // colon + const scope = scopeStack[scopeStack.length - 1]!; + if (scope.has(key)) dups.push(key); + else scope.add(key); + parseValue(); + skipWs(); + if (text[i] === ',') { + i++; + skipWs(); + } + } + i++; // closing brace + scopeStack.pop(); + } else if (c === '[') { + i++; + skipWs(); + while (text[i] !== ']') { + parseValue(); + skipWs(); + if (text[i] === ',') { + i++; + skipWs(); + } + } + i++; + } else if (c === '"') { + parseString(); + } else { + while (i < n && !/[,}\]\s]/.test(text[i]!)) i++; + } + } + parseValue(); + return dups; +} + +// ─── Scenario 6: namespace registration completeness ─────────────────────── + +describe('i18n namespace registration', () => { + it('NAMESPACES array length matches the number of en/*.json files on disk', () => { + const dirname = path.dirname(fileURLToPath(import.meta.url)); + const enDir = path.join(dirname, 'en'); + const fileCount = fs.readdirSync(enDir).filter((f) => f.endsWith('.json')).length; + + expect(NAMESPACES.length).toBe(fileCount); + }); +}); + +// ─── Scenario 7: recursive key-flatten parity per namespace ──────────────── + +describe('i18n en/de key parity', () => { + for (const { name, en, de } of NAMESPACES) { + describe(`${name} en/de parity`, () => { + it('has identical key sets', () => { + const enKeys = new Set(flatten(en)); + const deKeys = new Set(flatten(de)); + + const missingInDe = [...enKeys].filter((k) => !deKeys.has(k)); + const extraInDe = [...deKeys].filter((k) => !enKeys.has(k)); + + expect(missingInDe).toEqual([]); + expect(extraInDe).toEqual([]); + }); + }); + } +}); + +// ─── Scenario 8: duplicate-key guard across all locale files ─────────────── + +describe('findDuplicateKeys parser self-tests', () => { + it('flags a genuine same-scope duplicate key', () => { + expect(findDuplicateKeys('{"a": {"x": 1, "x": 2}}')).toEqual(['x']); + }); + + it('does not flag identically named keys in different scopes', () => { + expect(findDuplicateKeys('{"a": {"x": 1}, "b": {"x": 2}}')).toEqual([]); + }); + + it('does not flag an interpolation placeholder adjacent to a nested object boundary', () => { + expect(findDuplicateKeys('{"a": {"b": "{{count}} items"}, "c": {"b": 2}}')).toEqual([]); + }); +}); + +describe('i18n duplicate JSON key guard', () => { + const dirname = path.dirname(fileURLToPath(import.meta.url)); + const locales = ['en', 'de'] as const; + + for (const locale of locales) { + const localeDir = path.join(dirname, locale); + const files = fs.readdirSync(localeDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + it(`${locale}/${file} has no duplicate keys within any single object`, () => { + const text = fs.readFileSync(path.join(localeDir, file), 'utf-8'); + expect(findDuplicateKeys(text)).toEqual([]); + }); + } + } +});