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
2 changes: 1 addition & 1 deletion .claude/agent-memory/product-architect/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- [Dual-rail aggregation](dual-rail-aggregation.md) — Rail A/B tagged-deposit invariants (#1891/PR #1894), residual-denominator rule, isSplit UNION
- [Source-report split inference](source-report-split-inference.md) — budgetLines[]/deposits[] are this-source-scoped, so †/‡ classification is a proxy; proposed `splitKind`; pdfmake `'2*'` width trap
- [Story reviews](story-reviews.md) — per-story and per-PR review log
- [Client PDF pipeline](client-pdf-pipeline.md) — ADR-034 report PDF generation, reportContent content/layout split (#1900), `dontBreakRows` silent-drop rule, document-level deduplicated legend (#1965) — ADR-034 B4 rule + legend addendum + Deviation Log landed in PR #1979, discharging the #1959 debt
- [Client PDF pipeline](client-pdf-pipeline.md) — ADR-034 report PDF generation, reportContent content/layout split (#1900), `dontBreakRows` silent-drop rule, document-level deduplicated legend (#1965) — ADR-034 B4 rule + legend addendum landed in PR #1979; per-locale header character budget + "no interface `t` in header/footer" (#1937/#1938, PR #1982) — **ADR-034 B-rule addendum still owed**
- [Diary drafts pattern](diary-drafts-pattern.md) — ADR-022 draft lifecycle via status column on parent table
- [EPIC-03 refinement](epic03-refinement.md) — 40 consolidated refinement items
- [EPIC-04 household items](epic04-household-items.md) · [EPIC-05 budget](epic05-budget.md) · [EPIC-17 i18n](epic17-i18n.md) · [EPIC-18 areas & trades](epic18-areas-trades.md)
Expand Down
52 changes: 46 additions & 6 deletions .claude/agent-memory/product-architect/client-pdf-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,15 @@ Regression to guard when adding a flag type: emitting one entry per flagged row.
The ADR-034 debt owed since #1959 is now paid. Two structurally different note kinds share the block below
the overview table and must never share a numbering scheme:

| Kind | Marker | Cardinality | Built by |
| --- | --- | --- | --- |
| Skipped-document note | `*N`, numbered, referenced by the owning row | one per skipped document | `overviewPdf.ts` at generation time (not in `ReportContent`) |
| Legend entry (`content.footnotes[]`) | repeated inline word label — `partial`, `less deposit` | **at most one per flag type per document** | `buildReportContent.ts` |
| Kind | Marker | Cardinality | Built by |
| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------ | ------------------------------------------------------------ |
| Skipped-document note | `*N`, numbered, referenced by the owning row | one per skipped document | `overviewPdf.ts` at generation time (not in `ReportContent`) |
| Legend entry (`content.footnotes[]`) | repeated inline word label — `partial`, `less deposit` | **at most one per flag type per document** | `buildReportContent.ts` |

B4's old generalized rule ("every footnote is referenced from the row that owns it") applied only to the
numbered kind and was reworded. Invariants now recorded in the ADR's legend addendum:

- `footnotes[].marker` is `sourceReports.table.{split,depositReduced}InlineLabel` — the *same* keys as
- `footnotes[].marker` is `sourceReports.table.{split,depositReduced}InlineLabel` — the _same_ keys as
`labels.{splitNote,depositReducedNote}` and as the inline label the row cell prints. Row↔legend joins by
**repetition of that literal**, not by id/index/number. NBSP in `less deposit` / `abzgl. Abschlag` is
load-bearing; `expect(footnotes[0].marker).toBe(content.labels.splitNote)` is the assertion that pins it.
Expand All @@ -307,6 +307,46 @@ numbered kind and was reworded. Invariants now recorded in the ADR's legend adde
- Adding a flag type = new `Set` + `size > 0` push in `buildReportContent.ts`, new boolean on
`ReportContentRow`, new inline label in `overviewPdf.ts`. Assert exact `footnotes.length` (not `>= 1`) on a
fixture where several rows share a flag.
- Preview/export parity trap: once markers became *words*, `ReportContentEditor`'s
- Preview/export parity trap: once markers became _words_, `ReportContentEditor`'s
`<span>{marker}:</span>{text}` ran them together while the PDF used `${marker}: ${text}`. Fixed in #1979 —
any change to either surface must keep the separator identical.

## Fixed-width column headers impose a per-locale character budget (#1937/#1938, PR #1982)

The overview table's columns are fixed-width (`VENDOR_WIDTH = 45`, `INVOICE_AMOUNT_WIDTH = 48`, …) and
pdfmake's `elasticWidth` never grows a fixed column to fit its own header. So **every DE translation of a
`sourceReports.table.*` header key is width-constrained**, and DE is always the binding locale.

- `buildHeaderCell` applies `buildUsageTextRuns` (per-token `wordBreak: 'break-all'`) to every header cell.
That is a *last-resort* fallback (pdfmake 0.3.x has no hyphenation), not the fix: a mid-word break with
no hyphen on a bank-facing document is a defect in its own right. The fix is a shorter localized label.
- #1937 shortened `vendor` `Auftragnehmer` → `Firma` and `invoiceAmount` `Rechnungsbetrag` → `Betrag`.
The break-all mechanism **must stay** — vendor *data* (server cap 200 chars, German compounds) still
needs it, and #1937 explicitly accepted broken vendor names as unfixable without a layout change.
- Correct guard: a real-render assertion that the header cell resolves to `positions.length === 1` in the
`de` locale. Character-count arithmetic is a weaker proxy (see recurring-patterns.md).
- `overviewPdf.test.ts:833-861` and `VENDOR_HEADER_WORST_CASE_LINES` use hardcoded `'Auftragnehmer'`
fixtures/literals, *not* the live bundle — so they survive translation changes, but their comments and
test titles rot into claiming to describe the live DE labels.
- Consumers of `labels.*`: `overviewPdf.ts` (PDF) and `ReportContentEditor.tsx` (`<th>` preview, mobile
card captions, column-toggle text). `ReportContentLabels` is `reportT`-derived and **not user-editable**,
so a shortened label is safe — and must be identical in both surfaces by design.
- Glossary tension: `glossary.json` maps `Vendor` → `Auftragnehmer`. PDF column-header short forms diverge
from glossary terms under a measured constraint; that exception needs recording *in glossary.json*, not
just in translator memory, or an audit reverts it.

### Running header/footer must source strings from the report content model

`merge.ts`'s `header:` callback took the interface `t` for the generated-at label and never passed the
value (#1938) — a bare label on pages 2+ of every multi-page report. Fixed in PR #1982 to
`` `${reportContent.labels.generatedAt}: ${reportContent.sourceInfo.generatedAtText}` ``, byte-identical to
the page-1 block in `overviewPdf.ts:531`. Rule (from #1909): **artifact content resolves through
`reportT`/`reportFormatters`; only edit affordances use the interface `t`.**

- **Still violating it: `merge.ts:134`** — `buildPageFooter(t('sourceReports.table.pageLabel'))`. With
interface DE / report EN the footer reads `Seite 2 / 5` under an English report. Needs a new
`pageLabel` on `ReportContentLabels`; flagged as a follow-up in the PR #1982 review.
- Header height budget: `headerFootprint()` (`pageGeometry.ts`) models only the LEFT stack (title +
two-line subheader = 57.2pt) + 20pt block margin → `PAGE_TOP_MARGIN = 93`. The generated-at line is the
right child of a two-column node at implicit `'*'` (~257pt on A4) in `small` style, so appending the
value cannot threaten the margin — even a two-line wrap (~18pt) stays far under the left stack.
25 changes: 22 additions & 3 deletions .claude/agent-memory/product-architect/recurring-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,25 @@ core formula against the original line by line — that divergence is where the
`splitByDepositsExcludingTagged` (PR #1894), where the residual expression was the sole difference and
the sole defect. Prefer an options flag over a fork; when a fork ships anyway, file the collapse follow-up.

### Forked *test harness* — `realRender.test.ts` re-implements merge.ts's docDefinition

`renderOverviewPdfContent` (`client/src/lib/reportPdf/realRender.test.ts` ~L136-159) hand-copies
production's pdfmake `header:`/`footer:` callbacks while its own docstring claims parity with merge.ts
("never hand-copied — #1929 AC11"). It imports `pageMargins`/`styles` but forks the callbacks. PR #1982
changed `merge.ts`'s header string and left the harness on the old expression, so every multi-page
real-render test (incl. the 3-page long-`sourceName` clipping test) measures a string production no
longer emits. **Whenever `merge.ts`'s docDefinition changes, grep this helper.** Fix direction: pass
`content` and build the same string, rather than re-deriving it.

### Proxy bound looser than the production threshold it guards

PR #1982's AC7 tests bound DE header labels at `floor(width / 5.19pt)` (an *average* glyph advance) —
8/9 chars — while production's own break trigger is `safeTokenChars(width, HEADER_WORST_CASE_CHAR_WIDTH_PT
= 10.4pt)` = 4 chars. An 8-char wide-glyph label passes the test and still breaks in the PDF. When a test
re-derives a width/size bound instead of importing the production constant, check which direction the
error runs: a bound *looser* than production's greenlights the regression it exists to catch. The real
guard there is the renderer-level `positions.length === 1` assertion.

## Test smells worth escalating in review

- A combined-path test that places the two interacting entities on **different** parents proves nothing
Expand Down Expand Up @@ -491,7 +510,7 @@ content — and check whether the replacement text preserves _meaning_ (`(abzgl.

## Enumerated multi-site doc fixes come back half-done (PR #1979 r2)

When a review finding names N sites for the same stale claim, expect the fix commit to update the *nearest*
When a review finding names N sites for the same stale claim, expect the fix commit to update the _nearest_
ones and miss the rest. #1979's HIGH 2 named four sites for "nothing populates `content.footnotes`"; the fix
updated the field-declaration comment and the spec header (both adjacent to the changed assertions) and left
the two class-docstring paragraphs — which contained the strongest form ("they can never be populated by the
Expand All @@ -501,10 +520,10 @@ Two habits that follow:

- **Re-grep the literal on re-review**, never trust the fix commit's diff to cover the enumeration. One
`grep -n -i footnote e2e/pages/ReportWizardPage.ts` found both misses instantly.
- **Check the test *name*, not just the body.** #1979 inverted Scenario 18's assertions to `toHaveCount(1)`
- **Check the test _name_, not just the body.** #1979 inverted Scenario 18's assertions to `toHaveCount(1)`
but left the Playwright title reading "and no footnote list anywhere on the page". A title that states the
inverse of its body is worse than a stale comment: it renders that way in every CI report and is the first
artifact a future reader uses to conclude the *body* drifted. Same for the `// Scenario NN:` block header.
artifact a future reader uses to conclude the _body_ drifted. Same for the `// Scenario NN:` block header.

Why this is worth blocking on (I did, r2): the POM class docstring is the contract the spec header points at
("See `ReportWizardPage.ts`'s class docstring for the full locator reference"), so a directive there plus a
Expand Down
23 changes: 23 additions & 0 deletions .claude/agent-memory/product-architect/story-reviews.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,26 @@ Open follow-ups I own or should file:
- Pre-hydration toggle window (F4): editing before the mount fetch resolves discards stored prefs for the
session. Practically unreachable; `usePreferences.isLoading` is available if it ever matters.
- `isLoaded` is dead API surface — returned by the hook, not destructured by `DataTable.tsx:171-172`.

## PR #1982 — #1937 (DE header word-break) + #1938 (running-header timestamp) — APPROVED

Two-line production diff (`merge.ts` header string, two DE strings) plus test updates. Verified locally:
`npx jest realRender -t '#1937'` (5 passed, incl. the two `positions.length === 1` real-render assertions)
and `npx jest reportPdf/merge.test -t 'pdfmake header callback'`. Note the jest invocation trap here:
`--modulePathIgnorePatterns='/.claude/worktrees/'` matches the worktree's own rootDir and silently yields
"0 files checked across 3 projects" — drop it when running inside a worktree.

AC6 of #1938 (header still fits `PAGE_TOP_MARGIN`) discharged by analysis, not a new test — see
client-pdf-pipeline.md for the footprint reasoning. AC4/AC5 are pinned discriminatingly because the mocked
interface `t` returns the bare key, so a regression to `t()` fails rather than passing.

Findings, all non-blocking: M1 forked harness header callback; M2 average-vs-worst-case bound in the new
AC7 tests; M3 four stale `Auftragnehmer`/`Rechnungsbetrag` cross-references (the `buildHeaderCell`
docstring one matters — it could lead someone to delete break-all protection vendor *data* still needs);
M4 undocumented glossary divergence (`Vendor` → `Auftragnehmer` vs `Firma`); L6 follow-up: `merge.ts:134`
footer page label still uses the interface `t`.

**Mine to do:** ADR-034 B-rule addendum — fixed-width columns impose a per-locale header character budget
(break-all is the fallback, a shorter label is the fix, real-render single-line assertion is the guard),
plus the companion rule that running headers/footers never use the interface `t`. Deliberately not made a
condition of this PR to avoid a wiki submodule bump on a two-string fix.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
name: pr-1937-1938-pdf-header-labels
description: #1937/#1938 PDF running-header value and DE column-fit label test updates (2026-08-04)
metadata:
type: project
---

## Fix 1 — #1938: Running header now shows label + value

**Production change**: `merge.ts` line 131 changed from
`t('sourceReports.table.generatedAt')` (i18n key only) to
`` `${reportContent.labels.generatedAt}: ${reportContent.sourceInfo.generatedAtText}` ``

**Test updated**: `merge.test.ts` — the header-callback assertion changed from
`'sourceReports.table.generatedAt'` to `'Generated At: 01/15/2026'`.
`makeContent()` has `labels.generatedAt: 'Generated At'` and `generatedAtText: '01/15/2026'`.
**Why:** The bare i18n key assertion let the value silently disappear again.

## Fix 2 — #1937: DE header labels fit their columns

**Production change**: `de/budget.json` `sourceReports.table.vendor`:
`"Auftragnehmer"` → `"Firma"` (5 chars, fits 45pt);
`sourceReports.table.invoiceAmount`: `"Rechnungsbetrag"` → `"Betrag"` (6 chars, fits 48pt)

**Pre-existing tests that were BROKEN by the translation change and needed updating:**

1. `realRender.test.ts` HIGH1 budget-overview test (was asserting 'Auftragnehmer'/'Rechnungsbetrag')
- The old test also asserted `positions.length > 1` (multi-line wrap). The new short words
render in 1 line, so assertions changed to `toEqual(1)`.
2. `realRender.test.ts` HIGH1 claim (6-col) test — same label updates.
3. `realRender.test.ts` production singleton describe (line ~2818) — 'Auftragnehmer' → 'Firma'.

**New tests added**: AC7 describe block at the end of `realRender.test.ts`:
- Length bounds: `content.labels.vendor.length <= 8`, `content.labels.invoiceAmount.length <= 9`
(derived from 5.19pt/char measured Roboto average advance at 10pt bold)
- Exact value pins: `tDe('...vendor') === 'Firma'`, `tDe('...invoiceAmount') === 'Betrag'`
- EN stability: `tEn('...vendor') === 'Vendor'`, `tEn('...invoiceAmount') === 'Invoice Amount'`

## `VENDOR_HEADER_WORST_CASE_LINES` — leave as-is

`overviewPdf.ts` still uses `'Auftragnehmer'.length` (13 chars) to compute `VENDOR_HEADER_WORST_CASE_LINES`.
This is the **designed worst-case upper bound** for space reservation — intentionally conservative,
independent of the current DE translation. Do not change it.

## Pattern: update ALL stale translation-value assertions when DE label changes

When a DE translation key changes, grep realRender.test.ts for the OLD string value — there are
typically 3+ places (HIGH1 tests + production singleton describe). All must be updated together
or tests fail at a confusing set of locations.

## Column-fit math reference

- Roboto 10pt bold average advance: 5.19pt/char (measured: "Auftragnehmer" 67.50pt / 13 chars)
- VENDOR_WIDTH (45pt) / 5.19 = 8.67 → floor = 8 chars
- INVOICE_AMOUNT_WIDTH (48pt) / 5.19 = 9.25 → floor = 9 chars
- Labels with a space (e.g. "Invoice Amount") are NOT subject to single-token width constraint —
pdfmake wraps at word boundaries, no break-all needed.
11 changes: 11 additions & 0 deletions .claude/agent-memory/translator/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ New `sourceReports.expand.*` (chevron-expand sub-tables for budget lines + depos

- [Audit pitfalls](audit-pitfalls.md) — incident history behind the mandatory 4-step full-coverage audit protocol: a parity-only audit missed 13 code-referenced keys (Area UI raw-key bug); loose substring greps flagged 52 false positives

## PDF Column Header Short Forms Under Width Constraint (Issue #1937, 2026-08-04)

`sourceReports.table.vendor` ("Auftragnehmer", 13 chars, 67.5pt) overflows its 45pt column. `sourceReports.table.invoiceAmount` ("Rechnungsbetrag", 15 chars, 78.66pt) overflows its 48pt column. Font: Roboto Bold 10pt, avg ~5.19pt/char from "Auftragnehmer" measurement.

Fixes applied (following the Abschlag measured-space-constraint precedent):

- `vendor`: "Auftragnehmer" → **"Firma"** (5 chars, ~26pt). Rationale: no standard German abbreviation of "Auftragnehmer" fits within 8 chars without ambiguity ("Auftr." could be Auftraggeber). "Firma" (company/firm) is universally clear to any German bank employee; column content (actual company names) makes context self-evident. Glossary note: this is a PDF column-header short form under a measured constraint — "Auftragnehmer" remains the canonical term everywhere else.
- `invoiceAmount`: "Rechnungsbetrag" → **"Betrag"** (6 chars, ~27pt). Rationale: no abbreviation of "Rechnungsbetrag" fits in 9 chars in a `Rechnungsnr.`-style form. "Betrag" (amount) is universally clear; it is unambiguous adjacent to "Zugeordneter Betrag" (allocated amount column), which remains unchanged per AC5.

General rule: when a glossary term overshoots a measured PDF column, prefer the shortest universally-understood German synonym or generic noun over a coined abbreviation that lacks standard status.

## Cover Letter Signature Block Keys (Issue #1932, 2026-08-02)

`sourceReports.editable.signatureLabel` → "Unterschrift"; `sourceReports.coverLetter.closing` → "Mit freundlichen Grüßen,"; `sourceReports.editable.closingLabel` → "Grußformel". Confirmed: neither "signature" nor "closing salutation" belongs in the glossary (grep across `glossary.json` for signature/closing/Gruß terms found nothing, and these are generic letter-writing vocabulary, not Cornerstone domain terms) — did not add.
Expand Down
4 changes: 2 additions & 2 deletions client/src/i18n/de/budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -1239,11 +1239,11 @@
"reference": "Referenz",
"generatedAt": "Erstellt am",
"pageLabel": "Seite",
"vendor": "Auftragnehmer",
"vendor": "Firma",
"invoiceNumber": "Rechnungsnr.",
"date": "Datum",
"status": "Status",
"invoiceAmount": "Rechnungsbetrag",
"invoiceAmount": "Betrag",
"allocatedAmount": "Zugeordneter Betrag",
"total": "Gesamt",
"refundNote": "(Rückerstattung)",
Expand Down
Loading