Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/agent-memory/qa-integration-tester/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function render(ui: ReactElement, options?: Parameters<typeof rtlRender>[1]) {
return rtlRender(<LocaleProvider>{ui}</LocaleProvider>, options);
}
```

Works even when `ui` already contains `<MemoryRouter>` — 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 `<LocaleProvider><MemoryRouter>`.
Expand All @@ -44,15 +45,15 @@ 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
`_formatPercent` at the two call sites). Fix belongs to `frontend-developer`, not QA.

### 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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -86,20 +87,21 @@ 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.

**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
Expand All @@ -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 <ComponentName> client/src --include='*.tsx' --include='*.ts' | grep -v '\.test\.' | grep -v
'/<ComponentName>\.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
'/<ComponentName>\.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).
Expand All @@ -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 <paths...>
--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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion .claude/agent-memory/ux-designer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions .claude/agent-memory/ux-designer/pr-review-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ Reviewed against the issue #1804 spec (see [feature-spec-history.md](feature-spe

- Only finding: informational nit on `<p>` hint as a direct child of `<dl>` (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading