From 747f00085ca9785fc6a35118b869dd48753f46d5 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 3 Aug 2026 21:16:57 +0200 Subject: [PATCH 01/42] fix(reports): guard in-flight AI generation against use-case changes (#1946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widen `guardedUpdate` dirty predicate to include `isGeneratingAi` — in-flight generation now triggers the discard confirmation on any context change - Add monotonic `aiGenerationTokenRef` token; confirmed discards bump it so the in-flight result is silently dropped on arrival - Token-guard `finally` block so a stale generation cannot clear a live generation's spinner - Conditional modal title/body copy for in-flight-only case; clear `skippedDocuments`/`aiError` in handler callbacks Fixes #1946 Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester Co-Authored-By: Claude translator --- .../product-architect/recurring-patterns.md | 57 ++- .../bug-1946-ai-staleness-guard.md | 56 +++ client/src/i18n/de/budget.json | 2 + client/src/i18n/en/budget.json | 2 + .../ReportWizardPage.aiGeneration.test.tsx | 424 ++++++++++++++++++ .../ReportWizardPage/ReportWizardPage.tsx | 40 +- 6 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 .claude/agent-memory/qa-integration-tester/bug-1946-ai-staleness-guard.md diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 65a674ab1..3f2327fbe 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -404,7 +404,60 @@ constraint is enforced by a comment instead of by the type**: Rule: **delete rather than comment.** A write path with no reader and no producer is how the #1929 round-3/round-4 confusion started — the code said one thing and the comment said another. When triaging a -"keep it as a capability?" question, check for a *producer* first: no producer => dead, remove it. +"keep it as a capability?" question, check for a _producer_ first: no producer => dead, remove it. + +## Staleness tokens: the `finally` block is the hole (#1946 / PR #1977) + +A monotonic-token guard (`if (ref.current !== token) return;` in `.then`/`.catch`) does **not** protect a +`finally` block — `return` inside `try` still runs `finally`. So `finally { setIsLoading(false) }` lets an +_abandoned_ request clear a flag that a _newer_ request now owns. + +PR #1977 shipped exactly this in `runAiGeneration`: discard-confirm bumps the token and clears +`isGeneratingAi`; the user starts a second generation; the first one resolves ~seconds later, bails at the +token check, and its `finally` stops the second one's spinner, re-enables the trigger button, **and drops +`isGeneratingAi` out of `guardedUpdate`'s dirty predicate — reintroducing the very bug the PR fixed**, one +discard later. Note the `finally` was safe _before_ the token existed (the button gated concurrency), so this +is a defect introduced by widening the lifecycle without widening the flag's ownership check. + +**Review rules for any staleness-token PR:** + +1. Read the whole `try/catch/finally`, not just the two guard lines. Every side effect in `finally` needs the + same `ref.current === token` condition. +2. Ask "can a _second_ request now overlap the first?" A token bump usually re-enables a trigger that + concurrency was previously gated on. Demand a test that starts request B while A is still pending and + asserts A's arrival changes **nothing** — the new-describe blocks in these PRs test only A-alone. +3. Anything derived from the flag (elapsed-seconds timers, disabled states, and especially **dirty + predicates**) inherits the bug. Enumerate the flag's readers: `grep -n ` and check each. +4. `reportRequestRef` in the same file is the clean template precisely because it has no `finally`. + +Related: the conditional `if (isGeneratingAi)` inside a `pendingChangeRef` closure reads the value captured +at guard time, not at confirm time. Idempotent invalidation (always bump, always clear) removes the +stale-closure reasoning for free — prefer it. + +**Resolved in round 2** (`83afc72f`): `finally { if (aiGenerationTokenRef.current === token) setIsGeneratingAi(false) }`, +plus a two-controlled-promise test (start A, discard, start B, resolve A → assert still-disabled, resolve B → +assert content). Rule 2's "demand a test that starts B while A is pending" is what produced that test — keep asking. + +## Discard/confirm dialogs: conditionalize the title, not just the body + +Same PR: the body got an accurate in-flight variant, the title stayed `"Discard your edits?"` in the case +where no edits exist — and the AC5 test asserted that title, pinning the inaccuracy. When an AC says "copy +must not claim edits exist that do not", the title is copy and it is the most prominent line. Check every +string in the modal, and check the test isn't locking in the wrong one. + +Round-2 fix pattern worth reusing: select the title with the **byte-identical predicate expression** already +used for the body, not a re-derived equivalent — a copied-and-tweaked predicate is exactly how the two drift +apart again. + +### A conditional modal title breaks E2E page-object dialog locators + +Playwright POMs address dialogs by accessible name (`page.getByRole('dialog', { name: 'Discard your edits?' })`, +`e2e/pages/ReportWizardPage.ts`), so making a title conditional silently narrows that locator to one branch. +In PR #1977 nothing broke — the only E2E usage opens the modal *after* generation resolved, so the old title +still renders — but the POM docstring now documents the title as unconditional, and the next test that opens +the modal mid-generation will fail to find the dialog. **Whenever a PR conditionalizes any modal/heading string, +grep `e2e/pages/` for the literal** and flag the locator + docstring as an e2e-test-engineer follow-up. Same +family as the cross-reference-rot entry: a POM docstring is a contract surface, not a comment. ## Verify the AC record when a PR reverses a recently-shipped story (PR #1959) @@ -416,5 +469,5 @@ Tell: the reversing issue had **zero comments**, no `**[product-owner]**` header a PR-style summary), and no ux-designer visual spec — whereas the story it reversed had all four. **A requirements reversal authored as a polish issue is the signature.** Cheap fix: PO supersession comment on the old issue + PO ratification on the new one. Check this whenever a PR deletes user-visible report/document -content — and check whether the replacement text preserves *meaning* (`(abzgl. Abschlag)` lost the footnote's +content — and check whether the replacement text preserves _meaning_ (`(abzgl. Abschlag)` lost the footnote's "claimed separately", which is compliance-relevant in a bank-facing document). diff --git a/.claude/agent-memory/qa-integration-tester/bug-1946-ai-staleness-guard.md b/.claude/agent-memory/qa-integration-tester/bug-1946-ai-staleness-guard.md new file mode 100644 index 000000000..0080ef221 --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/bug-1946-ai-staleness-guard.md @@ -0,0 +1,56 @@ +--- +name: bug-1946-ai-staleness-guard +description: #1946 in-flight AI generation staleness guard tests — patterns and gotchas +metadata: + type: project +--- + +## PR/Story: Bug #1946 — in-flight AI staleness guard (2026-08-03) + +**File modified**: `client/src/pages/ReportWizardPage/ReportWizardPage.aiGeneration.test.tsx` + +Added `describe('in-flight staleness guard (#1946)')` with 10 tests (36 total in file after H1 added). + +### Key patterns + +**Controlled promise for race tests** (AC2, AC3, AC4): + +```ts +let resolveAiGeneration!: (value: GenerateReportContentResponse) => void; +const controlledPromise = new Promise((res) => { + resolveAiGeneration = res; +}); +mockGenerateReportContent.mockReturnValueOnce(controlledPromise); +// ... test body ... +await act(async () => { + resolveAiGeneration(defaultAiResult()); +}); +``` + +Use `await act(async () => { resolve(...); })` to flush the microtask chain and React state updates together. Plain `act(() => {...})` (sync) does NOT flush the async continuation. + +**Never-resolving promise** (AC1, AC5a, AC6): use `mockReturnValueOnce(new Promise(() => {}))` not `mockReturnValue` to keep test isolation clean. + +**Step-4 checkbox as the guarded trigger**: the "Attach invoice PDFs" checkbox (`getByLabelText('Attach invoice PDFs')`) on step 4 calls `guardedUpdate`. It's always enabled (unlike "Include cover letter" which is disabled when source has no contactAddress/reference). One Back click from step 5 reaches step 4. This is cleaner than step-3 invoice toggle (which would disable the Next button when all invoices are excluded). + +**Navigation pattern for modal trigger**: + +``` +goToStep5 → user.click(Back) [5→4] → user.click(getByLabelText('Attach invoice PDFs')) → modal +``` + +**After confirm + navigate back to step 5**: use `clickNext(user)` from step 4 (one click). Token is already incremented, so the resolved promise bails silently. + +**AC9 / AC10 re-navigation**: after a use-case or source change resets state, re-navigate forward through the wizard the same way `goToStep5` would — click source radio, wait for Next enabled, clickNext ×3. + +**skippedDocuments via Preview PDF** (AC10): `user.click(button 'Preview PDF')` → wait for `getByText('PDF Preview')` (modal title) → `user.keyboard('{Escape}')` → wait for modal gone → skipped docs visible in step-5 body. + +**Discard modal title and body distinction**: + +- `isGeneratingAi && overrides empty && aiContent null` → title `discardConfirmTitleGenerating` ("Cancel AI generation?"), body `discardConfirmBodyGenerating` ("An AI generation is in progress...") +- any other dirty state → title `discardConfirmTitle` ("Discard your edits?"), body `discardConfirmBody` ("Changing this will regenerate...") + +Tests AC1, AC2, AC3, AC6 (in-flight only) assert `'Cancel AI generation?'` as the title. +Tests AC4, AC5b, AC10's guardedUpdate check, and the step-3 invoice toggle test assert `'Discard your edits?'` (overrides or aiContent present). + +**Why:** `jest.clearAllMocks()` clears call records but NOT implementations. Use `mockReturnValueOnce` (not `mockReturnValue`) for one-shot responses to prevent leaking never-resolving promise default to later tests. diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json index 7d2445cbd..559ef39b5 100644 --- a/client/src/i18n/de/budget.json +++ b/client/src/i18n/de/budget.json @@ -1210,7 +1210,9 @@ "previewPdf": "PDF-Vorschau anzeigen", "previewModalTitle": "PDF-Vorschau", "discardConfirmTitle": "Ihre Bearbeitungen verwerfen?", + "discardConfirmTitleGenerating": "KI-Generierung abbrechen?", "discardConfirmBody": "Dadurch wird der Berichtsinhalt neu generiert und Ihre Bearbeitungen gehen verloren.", + "discardConfirmBodyGenerating": "Eine KI-Generierung läuft gerade. Durch diese Änderung wird sie abgebrochen.", "discardAndContinue": "Verwerfen und Fortfahren", "keepEditing": "Weiter bearbeiten", "coverLetterHeading": "Anschreiben", diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json index 67061da91..b304e26f0 100644 --- a/client/src/i18n/en/budget.json +++ b/client/src/i18n/en/budget.json @@ -1210,7 +1210,9 @@ "previewPdf": "Preview PDF", "previewModalTitle": "PDF Preview", "discardConfirmTitle": "Discard your edits?", + "discardConfirmTitleGenerating": "Cancel AI generation?", "discardConfirmBody": "Changing this will regenerate the report content and your edits will be lost.", + "discardConfirmBodyGenerating": "An AI generation is in progress. Changing this will cancel it.", "discardAndContinue": "Discard and Continue", "keepEditing": "Keep Editing", "coverLetterHeading": "Cover Letter", diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.aiGeneration.test.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.aiGeneration.test.tsx index d654e098f..1211cf09a 100644 --- a/client/src/pages/ReportWizardPage/ReportWizardPage.aiGeneration.test.tsx +++ b/client/src/pages/ReportWizardPage/ReportWizardPage.aiGeneration.test.tsx @@ -845,4 +845,428 @@ describe('ReportWizardPage — AI generation (Story #1901, revised by #1931)', ( ); }); }); + + // ─── In-flight staleness guard (#1946) ───────────────────────────────────── + + describe('in-flight staleness guard (#1946)', () => { + // AC1: guardedUpdate's widened predicate fires when isGeneratingAi is true, + // even though overrides and aiContent are both absent. + it('AC1 — shows discard modal while generation is in-flight with no overrides or aiContent', async () => { + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(new Promise(() => {})); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Navigate to step 4 and trigger a guarded setting change. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + + expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument(); + }); + + // AC2: confirming discard increments the token so the in-flight result is + // silently discarded when it eventually arrives. + it('AC2 — confirming discard invalidates in-flight result', async () => { + let resolveAiGeneration!: (value: GenerateReportContentResponse) => void; + const controlledPromise = new Promise((res) => { + resolveAiGeneration = res; + }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(controlledPromise); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Navigate to step 4 and trigger the discard modal. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument()); + + // Confirm: token incremented, isGeneratingAi set false immediately. + await user.click(screen.getByRole('button', { name: 'Discard and Continue' })); + + // Navigate back to step 5. + await clickNext(user); // 4 -> 5 + + // Resolve the now-invalidated promise — token mismatch silently discards it. + await act(async () => { + resolveAiGeneration(defaultAiResult()); + }); + + // AI result must NOT appear. + expect( + within(desktopTable()).queryByDisplayValue('AI-generated usage description'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Content generated with AI — review before submitting.'), + ).not.toBeInTheDocument(); + // Spinner must be gone (isGeneratingAi was set false by the discard confirm). + expect(screen.queryByText(/Generating…/)).not.toBeInTheDocument(); + // Original baseline is shown. + expect(within(desktopTable()).getByDisplayValue('Original Usage Text')).toBeInTheDocument(); + }); + + // AC3: cancelling the discard leaves the token unchanged so the in-flight + // result lands normally when the promise resolves. + it('AC3 — cancelling discard lets in-flight generation complete normally', async () => { + let resolveAiGeneration!: (value: GenerateReportContentResponse) => void; + const controlledPromise = new Promise((res) => { + resolveAiGeneration = res; + }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(controlledPromise); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Trigger modal, then cancel. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Keep Editing' })); + expect(screen.queryByText('Cancel AI generation?')).not.toBeInTheDocument(); + + // Navigate back to step 5 — generation still in-flight. + await clickNext(user); // 4 -> 5 + + // Resolve the still-live promise — token unchanged so result applies. + await act(async () => { + resolveAiGeneration(defaultAiResult()); + }); + + await waitFor(() => { + expect( + within(desktopTable()).getByDisplayValue('AI-generated usage description'), + ).toBeInTheDocument(); + }); + expect( + screen.getByText('Content generated with AI — review before submitting.'), + ).toBeInTheDocument(); + }); + + // AC4: when the user has typed overrides AND a generation is in-flight, + // confirming discard invalidates both — the resolved result does not + // silently re-populate aiContent or restore the discarded override. + it('AC4 — confirmed discard invalidates both overrides and in-flight result', async () => { + let resolveAiGeneration!: (value: GenerateReportContentResponse) => void; + const controlledPromise = new Promise((res) => { + resolveAiGeneration = res; + }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(controlledPromise); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Create a manual override while generation is in-flight. + const usageInput = within(desktopTable()).getByDisplayValue('Original Usage Text'); + fireEvent.change(usageInput, { target: { value: 'Manual edit before discard' } }); + + // Trigger the discard modal and confirm. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Discard your edits?')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Discard and Continue' })); + + // Navigate back to step 5. + await clickNext(user); // 4 -> 5 + + // Resolve the invalidated promise. + await act(async () => { + resolveAiGeneration(defaultAiResult()); + }); + + // Neither the manual edit nor the AI result must appear. + expect( + within(desktopTable()).queryByDisplayValue('Manual edit before discard'), + ).not.toBeInTheDocument(); + expect( + within(desktopTable()).queryByDisplayValue('AI-generated usage description'), + ).not.toBeInTheDocument(); + // Original baseline is restored. + expect(within(desktopTable()).getByDisplayValue('Original Usage Text')).toBeInTheDocument(); + }); + + // AC5a: the modal body uses the "generating" copy when only an in-flight + // generation exists — no overrides, no aiContent. + it('AC5a — modal body shows generating copy when in-flight with no overrides or aiContent', async () => { + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(new Promise(() => {})); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument()); + + expect( + screen.getByText('An AI generation is in progress. Changing this will cancel it.'), + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'Changing this will regenerate the report content and your edits will be lost.', + ), + ).not.toBeInTheDocument(); + }); + + // AC5b: once a generation completes (aiContent is set, isGeneratingAi is + // false), the modal body falls back to the existing edits copy. + it('AC5b — modal body shows edits copy when aiContent is set and generation is complete', async () => { + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockResolvedValue(defaultAiResult()); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => { + expect( + within(desktopTable()).getByDisplayValue('AI-generated usage description'), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Discard your edits?')).toBeInTheDocument()); + + expect( + screen.getByText( + 'Changing this will regenerate the report content and your edits will be lost.', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText('An AI generation is in progress. Changing this will cancel it.'), + ).not.toBeInTheDocument(); + }); + + // AC6: the fix lives in guardedUpdate, so every guarded transition — not + // just use-case changes — is protected. Verify with a source change (step 2). + it('AC6 — source change also shows discard modal while generation is in-flight', async () => { + const source2 = makeSource({ id: 'src-2', name: 'Equity Fund' }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource(), source2] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(new Promise(() => {})); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Navigate back to step 2. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByRole('button', { name: 'Back' })); // 4 -> 3 + await user.click(screen.getByRole('button', { name: 'Back' })); // 3 -> 2 + + // Click the second source radio — handleSourceChange -> guardedUpdate -> modal. + await waitFor(() => expect(screen.getAllByRole('radio').length).toBeGreaterThan(1)); + await user.click(screen.getAllByRole('radio')[1]!); // src-2 + + expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument(); + }); + + // AC9: handleUseCaseChange carries a setAiError('') call in its guarded + // callback. Verify it clears a stale error left from a prior failed generation. + it('AC9 — handleUseCaseChange clears aiError from a prior failed generation', async () => { + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockRejectedValueOnce(new Error('server error')); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByText('AI generation failed. Please try again.')).toBeInTheDocument(), + ); + + // Navigate back to step 1 via four Back clicks. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByRole('button', { name: 'Back' })); // 4 -> 3 + await user.click(screen.getByRole('button', { name: 'Back' })); // 3 -> 2 + await user.click(screen.getByRole('button', { name: 'Back' })); // 2 -> 1 + + // Select "budget-overview" (first radio) — triggers handleUseCaseChange, + // which runs setAiError('') inside its guarded callback (isDirty is false + // since the error path left isGeneratingAi=false, overrides={}, aiContent=null). + await waitFor(() => screen.getByRole('radiogroup')); + await user.click(screen.getAllByRole('radio')[0]!); // budget-overview + + // Re-navigate to step 5 under the new use case. + await clickNext(user); // 1 -> 2 + await waitFor(() => expect(screen.getAllByRole('radio').length).toBeGreaterThan(0)); + await user.click(screen.getAllByRole('radio')[0]!); // src-1 + await waitFor(() => { + const primaryButtons = screen + .getAllByRole('button') + .filter((b) => b.className.includes('btnPrimary')); + expect(primaryButtons[primaryButtons.length - 1]).not.toBeDisabled(); + }); + await clickNext(user); // 2 -> 3 + await waitFor(() => expect(screen.getByText('ACME')).toBeInTheDocument()); + await clickNext(user); // 3 -> 4 + await clickNext(user); // 4 -> 5 + + // The stale error banner must be gone. + expect(screen.queryByText('AI generation failed. Please try again.')).not.toBeInTheDocument(); + }); + + // AC10: handleSourceChange clears skippedDocuments so a stale "document + // could not be fetched" note from a previous source's PDF run does not + // bleed into the next source's step 5 view. + it('AC10 — handleSourceChange clears skippedDocuments', async () => { + const source2 = makeSource({ id: 'src-2', name: 'Equity Fund' }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource(), source2] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + // First PDF generation returns a skipped document; subsequent calls return none + // (the default set in beforeEach — mockResolvedValue — kicks in after this Once). + mockGenerateReportPdf.mockResolvedValueOnce({ + blob: new Blob(['pdf']), + skippedDocuments: [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed' as const, + vendorName: 'ACME', + invoiceNumber: 'INV-001', + }, + ], + }); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + // Click "Preview PDF" to populate skippedDocuments state. + await user.click(screen.getByRole('button', { name: 'Preview PDF' })); + await waitFor(() => expect(screen.getByText('PDF Preview')).toBeInTheDocument()); + + // Close the preview modal. + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByText('PDF Preview')).not.toBeInTheDocument()); + + // Skipped-document note must be visible on step 5 before the source change. + expect(screen.getByText(/Document could not be retrieved/)).toBeInTheDocument(); + + // Navigate back to step 2 via three Back clicks. + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByRole('button', { name: 'Back' })); // 4 -> 3 + await user.click(screen.getByRole('button', { name: 'Back' })); // 3 -> 2 + + // Change to source 2 — handleSourceChange -> setSkippedDocuments([]). + await waitFor(() => expect(screen.getAllByRole('radio').length).toBeGreaterThan(1)); + await user.click(screen.getAllByRole('radio')[1]!); // src-2 + + // Navigate to step 5 under src-2. + await waitFor(() => { + const primaryButtons = screen + .getAllByRole('button') + .filter((b) => b.className.includes('btnPrimary')); + expect(primaryButtons[primaryButtons.length - 1]).not.toBeDisabled(); + }); + await clickNext(user); // 2 -> 3 + await waitFor(() => expect(screen.getByText('ACME')).toBeInTheDocument()); + await clickNext(user); // 3 -> 4 + await clickNext(user); // 4 -> 5 + + // Stale skipped-document note must be gone. + expect(screen.queryByText(/Document could not be retrieved/)).not.toBeInTheDocument(); + }); + + // H1: a discarded generation's finally block must NOT clear the spinner that + // belongs to a second, still-in-flight generation started after the discard. + it('H1 — discarded generation\'s finally does not clear spinner of new generation', async () => { + let resolveA!: (value: GenerateReportContentResponse) => void; + const controlledA = new Promise((res) => { + resolveA = res; + }); + let resolveB!: (value: GenerateReportContentResponse) => void; + const controlledB = new Promise((res) => { + resolveB = res; + }); + mockFetchBudgetSources.mockResolvedValue({ budgetSources: [makeSource()] }); + mockGetSourceReport.mockResolvedValue(makeReport()); + mockGenerateReportContent.mockReturnValueOnce(controlledA).mockReturnValueOnce(controlledB); + renderPage(); + const user = userEvent.setup(); + await goToStep5(user); + + // Start generation A. + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Navigate to step 4 and trigger the discard modal (title is the generating variant). + await user.click(screen.getByRole('button', { name: 'Back' })); // 5 -> 4 + await user.click(screen.getByLabelText('Attach invoice PDFs')); + await waitFor(() => expect(screen.getByText('Cancel AI generation?')).toBeInTheDocument()); + + // Confirm discard — token bumped, isGeneratingAi cleared. + await user.click(screen.getByRole('button', { name: 'Discard and Continue' })); + + // Navigate back to step 5 and start generation B. + await clickNext(user); // 4 -> 5 + await user.click(screen.getByRole('button', { name: 'Enhance with AI' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(), + ); + + // Resolve A — its token is stale so the finally block must NOT clear + // isGeneratingAi (which now belongs to B). + await act(async () => { + resolveA(defaultAiResult()); + }); + + // B is still in-flight: button must still be disabled. + expect(screen.getByRole('button', { name: 'Enhance with AI' })).toBeDisabled(); + + // Resolve B — this generation is live, so its result applies. + await act(async () => { + resolveB(defaultAiResult()); + }); + + // Button re-enables and AI content appears. + await waitFor(() => + expect(screen.getByRole('button', { name: 'Enhance with AI' })).not.toBeDisabled(), + ); + expect( + within(desktopTable()).getByDisplayValue('AI-generated usage description'), + ).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx index 226169b86..8950888ac 100644 --- a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx +++ b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx @@ -146,6 +146,7 @@ export function ReportWizardPage() { // a fetch starts and checking it in every callback before writing state discards any response // that isn't from the most recently started fetch, in either the success or error path. const reportRequestRef = useRef(0); + const aiGenerationTokenRef = useRef(0); // PDF preview modal const [showPdfPreviewModal, setShowPdfPreviewModal] = useState(false); @@ -196,14 +197,20 @@ export function ReportWizardPage() { void init(); }, []); - // Guard for mutations: if overrides or aiContent exist, commit & show confirm modal; else apply change immediately + // Guard for mutations: if overrides, aiContent, or an in-flight generation exist, show confirm modal; else apply change immediately const guardedUpdate = useCallback( (applyChange: () => void) => { - const isDirty = Object.keys(overrides).length > 0 || aiContent !== null; + const hasEdits = Object.keys(overrides).length > 0 || aiContent !== null; + const isDirty = hasEdits || isGeneratingAi; if (isDirty) { pendingChangeRef.current = () => { setOverrides({}); setAiContent(null); + if (isGeneratingAi) { + aiGenerationTokenRef.current += 1; + setIsGeneratingAi(false); + setAiError(''); + } applyChange(); }; setShowDiscardConfirm(true); @@ -211,7 +218,7 @@ export function ReportWizardPage() { applyChange(); } }, - [overrides, aiContent], + [overrides, aiContent, isGeneratingAi], ); // Handle use case selection @@ -234,6 +241,8 @@ export function ReportWizardPage() { setSourceId(null); setExcludedInvoiceIds(new Set()); setExcludedLineIds(new Set()); + setSkippedDocuments([]); + setAiError(''); // Fetch amounts for all sources in parallel Promise.all( @@ -262,6 +271,7 @@ export function ReportWizardPage() { setSourceId(sid); setExcludedInvoiceIds(new Set()); setExcludedLineIds(new Set()); + setSkippedDocuments([]); setMaxReachedStep(3); setReportStatus('loading'); @@ -607,6 +617,8 @@ export function ReportWizardPage() { return; } + // #1946: Capture token BEFORE setting isGeneratingAi + const token = ++aiGenerationTokenRef.current; setIsGeneratingAi(true); setAiError(''); @@ -619,16 +631,24 @@ export function ReportWizardPage() { excludedLineIds: Array.from(excludedLineIds), }); + // Token mismatch: user discarded this generation while in flight + if (aiGenerationTokenRef.current !== token) return; + setAiContent(result); setOverrides({}); } catch (err) { + // Token mismatch: do not surface error for discarded generation + if (aiGenerationTokenRef.current !== token) return; + if (err instanceof ApiClientError) { setAiError(translateApiError(err.error.code, tErrors)); } else { setAiError(t('sourceReports.editable.aiGenerationFailed')); } } finally { - setIsGeneratingAi(false); + if (aiGenerationTokenRef.current === token) { + setIsGeneratingAi(false); + } } }, [report, useCase, excludedLineIds, excludedInvoiceIds, sourceId, reportLanguage, t, tErrors]); @@ -996,7 +1016,11 @@ export function ReportWizardPage() { {/* Discard edits confirmation modal */} {showDiscardConfirm && ( { setShowDiscardConfirm(false); pendingChangeRef.current = null; @@ -1027,7 +1051,11 @@ export function ReportWizardPage() { } > -

{t('sourceReports.editable.discardConfirmBody')}

+

+ {isGeneratingAi && Object.keys(overrides).length === 0 && aiContent === null + ? t('sourceReports.editable.discardConfirmBodyGenerating') + : t('sourceReports.editable.discardConfirmBody')} +

)} From 66429c3383b1a7cfb4b6388c153cb98838e2a912 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 3 Aug 2026 21:25:37 +0200 Subject: [PATCH 02/42] fix(budget): show actual invoiced amount in source lines and used total Co-Authored-By: Claude backend-developer Co-Authored-By: Claude frontend-developer --- .../SourceBudgetLinePanel.tsx | 8 ++++++-- server/src/services/budgetSourceService.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx b/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx index f8fbdcddc..1a092e194 100644 --- a/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx +++ b/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx @@ -581,7 +581,9 @@ export function SourceBudgetLinePanel({ {invoiceStatusLabel} - {formatCurrency(line.plannedAmount)} + {formatCurrency( + line.invoiceCount > 0 ? line.actualCost : line.plannedAmount, + )} ); @@ -614,7 +616,9 @@ export function SourceBudgetLinePanel({ {invoiceStatusLabel} - {formatCurrency(line.plannedAmount)} + {formatCurrency( + line.invoiceCount > 0 ? line.actualCost : line.plannedAmount, + )} ); diff --git a/server/src/services/budgetSourceService.ts b/server/src/services/budgetSourceService.ts index 5744f78fd..e07e3e28a 100644 --- a/server/src/services/budgetSourceService.ts +++ b/server/src/services/budgetSourceService.ts @@ -6,6 +6,7 @@ import { budgetSources, workItemBudgets, householdItemBudgets, + invoiceBudgetLines, users, workItems, householdItems, @@ -108,16 +109,22 @@ function toBudgetSource( /** * Compute the used amount for a budget source. - * Sums planned_amount from both work_item_budgets and household_item_budgets where budget_source_id matches. + * For invoiced lines uses the actual itemized amount; for non-invoiced lines uses planned_amount. * Returns 0 if no budget lines reference this source. */ function computeUsedAmount(db: DbType, sourceId: string): number { const result = db.get<{ total: number }>( - sql`SELECT COALESCE(SUM(planned_amount), 0) AS total + sql`SELECT COALESCE(SUM(effective_amount), 0) AS total FROM ( - SELECT planned_amount FROM ${workItemBudgets} WHERE budget_source_id = ${sourceId} + SELECT COALESCE(ibl.itemized_amount, wib.planned_amount) AS effective_amount + FROM ${workItemBudgets} wib + LEFT JOIN ${invoiceBudgetLines} ibl ON ibl.work_item_budget_id = wib.id + WHERE wib.budget_source_id = ${sourceId} UNION ALL - SELECT planned_amount FROM ${householdItemBudgets} WHERE budget_source_id = ${sourceId} + SELECT COALESCE(ibl.itemized_amount, hib.planned_amount) AS effective_amount + FROM ${householdItemBudgets} hib + LEFT JOIN ${invoiceBudgetLines} ibl ON ibl.household_item_budget_id = hib.id + WHERE hib.budget_source_id = ${sourceId} )`, ); return result?.total ?? 0; From 6dc2c660230bedd69d6de5ea4c12ec482d1bc130 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Mon, 3 Aug 2026 22:41:02 +0200 Subject: [PATCH 03/42] fix(reports): reinstate legend sentences for split and deposit-reduced rows (#1965) - Reinstate explanatory legend sentences for split (partial) and deposit-reduced rows in the report PDF - Fix JSX whitespace between footnote marker and text for preview/PDF rendering parity - Invert E2E Scenario 18 to assert one deduplicated legend entry; update stale POM directives - Strengthen unit test assertions: marker equality, i18n text, whitespace-parity guard on
  • - Correct ADR-034 to document document-level deduplicated legend model (vs. per-row footnotes) Fixes #1965 Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude e2e-test-engineer Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester --- .../issue-1959-inline-meta-and-labels.md | 25 +++-- .../agent-memory/product-architect/MEMORY.md | 4 +- .../product-architect/client-pdf-pipeline.md | 61 +++++++++++-- .../product-architect/recurring-patterns.md | 42 ++++++++- .../story-1965-report-legend.md | 68 ++++++++++++++ .../reports/ReportContentEditor.test.tsx | 29 ++++-- .../reports/ReportContentEditor.tsx | 3 +- .../reportContent/buildReportContent.test.ts | 91 +++++++++++-------- .../lib/reportContent/buildReportContent.ts | 16 ++++ client/src/lib/reportPdf/realRender.test.ts | 32 +++++-- e2e/pages/ReportWizardPage.ts | 34 ++++--- .../reportWizardEditableContent.spec.ts | 49 +++++----- wiki | 2 +- 13 files changed, 349 insertions(+), 107 deletions(-) create mode 100644 .claude/agent-memory/qa-integration-tester/story-1965-report-legend.md diff --git a/.claude/agent-memory/e2e-test-engineer/issue-1959-inline-meta-and-labels.md b/.claude/agent-memory/e2e-test-engineer/issue-1959-inline-meta-and-labels.md index 5375285b2..ffc5485fc 100644 --- a/.claude/agent-memory/e2e-test-engineer/issue-1959-inline-meta-and-labels.md +++ b/.claude/agent-memory/e2e-test-engineer/issue-1959-inline-meta-and-labels.md @@ -1,6 +1,6 @@ --- name: issue-1959-inline-meta-and-labels -description: PR #1959 reversed two earlier report-table designs (†/‡ shared footnotes from #1923, distinct area sub-line) into inline labels + one combined meta line; which E2E locators/scenarios had to be rewritten and how each new assertion was made non-vacuous. +description: PR #1959 reversed two earlier report-table designs (†/‡ shared footnotes from #1923, distinct area sub-line) into inline labels + one combined meta line; which E2E locators/scenarios had to be rewritten and how each new assertion was made non-vacuous. Issue #1965 then reinstated legend footnotes for split/depositReduced rows. metadata: type: project --- @@ -9,11 +9,22 @@ PR #1959 ("improve report PDF UX") deliberately **superseded** two designs earli asked for, in `ReportContentEditor.tsx` / `buildReportContent.ts`: 1. `†`/`‡` markers + the shared footnote list (Story #1923 AC1) → grey inline `` in the **Allocated Amount cell**: `(partial)` / `(less deposit)` +class*="inlineNote">` in the **Allocated Amount cell**: `(partial)` / `(less deposit)` (de `(Teilbetrag)` / `(abzgl. Abschlag)`). `ReportContentRow.allocatedMarkers` → `isSplit` / - `isDepositReduced` booleans. `buildReportContent` now pushes **zero** footnotes, so - `.footnotes` has no producer at all — `footnotesBlock`/`footnoteItems` survive in the POM as + `isDepositReduced` booleans. `buildReportContent` pushed **zero** footnotes after #1959, so + `.footnotes` had no producer — `footnotesBlock`/`footnoteItems` survived in the POM as **negative-only** guards. + + **Issue #1965 update (fix/report-pdf-ux-improvements branch):** `buildReportContent.ts` now + pushes ONE deduplicated legend entry per active flag: `splitInvoiceIds.size > 0` → one `'split'` + footnote ("Amount shown reflects only the portion allocated to this source."), + `depositReducedInvoiceIds.size > 0` → one `'depositReduced'` footnote. `footnotesBlock` / + `footnoteItems` are NO LONGER negative-only guards. Scenarios with split or deposit-reduced rows + must assert a **positive** count; constituted-deposit-only rows (Scenario 17) still assert + `toHaveCount(0)` because neither set is non-empty for them. Scenario 18 (two split invoices) + asserts `footnotesBlock` count=1, `footnoteItems` count=1, and the legend sentence IS present in + `main`'s text content. + 2. `.usageAreaText` sub-line + the separate editable `Attachments Note` column → ONE read-only `.usageMetaText` line inside the Usage cell: `[areaText, attachmentsNote].join(' · ')` (U+00B7 middle dot, spaces on both sides). The `attachmentsNote` `EditableField` is gone @@ -27,9 +38,9 @@ the PR body the spec, so the tests were rewritten, not the code. `mobileUsageAreaText`→`mobileUsageMetaText`, plus new `inlineNote()`/`mobileInlineNote()`; `attachmentsNoteField()` deleted. Rewritten scenarios: editableContent 2, 17, 18, 20 and aiGeneration 8. Every "old design is gone" negative is paired with a positive so it cannot pass -against a mis-seeded page (e.g. Scenario 18 asserts `(partial)` present *and* `†`/`‡` absent -*and* the long-form footnote sentence absent from `main`; Scenario 20 asserts the attachments -note text IS rendered *and* the row has one textbox). +against a mis-seeded page (e.g. Scenario 18 asserts `(partial)` present _and_ `†`/`‡` absent +_and_ the long-form footnote sentence absent from `main`; Scenario 20 asserts the attachments +note text IS rendered _and_ the row has one textbox). Facts worth reusing: diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 373f93876..96cac9dfa 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,11 +2,11 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), AC reversal by a polish issue (#1959) +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959) - [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 + **owed ADR-034 corrections** (#1959) +- [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 - [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) diff --git a/.claude/agent-memory/product-architect/client-pdf-pipeline.md b/.claude/agent-memory/product-architect/client-pdf-pipeline.md index 746bccc37..c44827a07 100644 --- a/.claude/agent-memory/product-architect/client-pdf-pipeline.md +++ b/.claude/agent-memory/product-architect/client-pdf-pipeline.md @@ -81,14 +81,14 @@ change to `overviewPdf.ts` widths, `TABLE_LAYOUT`, or `pageMargins` must be vali `/CreationDate` + `/ID`); a unit test asserting `TABLE_LAYOUT.dontBreakRows === true` passes and proves nothing. 2. **Declared widths are CONTENT widths.** pdfmake subtracts `_offsets.total` from the available - width *before* distributing them. `offsetsTotal = cols * (paddingLeft + paddingRight + - vLineWidth) + vLineWidth`. With `TABLE_LAYOUT`'s `8/8/0.5` that is **116.0pt for 7 columns, + width _before_ distributing them. `offsetsTotal = cols * (paddingLeft + paddingRight + +vLineWidth) + vLineWidth`. With `TABLE_LAYOUT`'s `8/8/0.5` that is **116.0pt for 7 columns, 99.5pt for 6** out of the 515.28pt A4 printable width. Budget columns against `515.28 - offsetsTotal(cols)`, not 515.28. Getting this wrong made a comment claim Usage got 185.28pt when it actually got **69.28pt**. 3. **A `'*'` column never shrinks below its longest unbreakable word.** `columnCalculator.js:66-75` — when `minW >= availableWidth` the star is set to `starMaxMin` and - *the table overflows the page*. So no static assertion on the `widths` array can prove "no + _the table overflows the page_. So no static assertion on the `widths` array can prove "no horizontal overflow": German compounds (`Wärmedämmverbundsystem` ~128pt @10pt Roboto) push a 69.28pt star to 128pt and the table to 574pt on a 515.28pt page. 4. **`dontBreakRows` + a row taller than the printable height = silent data loss.** pdfmake does @@ -234,13 +234,13 @@ document): page count must be **monotonic** in content size, and **channel-indep costs the same paper whichever field carries it. **The generic-assertion / hand-enumerated-input asymmetry** (the reason it recurred, now the top open item): -the per-row budget assertion counts characters generically over all runs, so a new channel *would* be +the per-row budget assertion counts characters generically over all runs, so a new channel _would_ be counted — but the test inputs are hand-listed (`usageText`/`areaText`/`attachmentsNote`), so a new `ReportContentRow` string field defaults empty and every assertion passes **vacuously**. Fix is key-driven saturation (`Object.entries(row)` -> saturate every string field), not another hand-written case. Verified empirically during review: losslessness + per-row budget hold over 3k fuzzed inputs (0 failures); a hypothetical **second** grey/meta segment trips the existing `splitUsageCell` "at most one grey run per row" -throw in ~49% of inputs, so *that* channel class is already guarded. +throw in ~49% of inputs, so _that_ channel class is already guarded. **Geometry constraint (blocks a feature):** `USAGE_WIDTH_7COL/_6COL` derive from `usableColumnWidth(n)`, and `MAX_SAFE_USAGE_CHUNK_CHARS = 650` was **measured against the 7-column shape**. So making column visibility @@ -251,13 +251,62 @@ through" is a re-measurement story, not a UI change. #1959's toggles are preview New to the packer — `splitIntoPageSafeChunks` fails loudly instead (`RangeError`). Unreachable while the budget is a constant; matters if it ever becomes computed. +### Legend is document-level, not per-row (#1965) + +DONE 2026-08-03: ADR-034 records this (wiki master `03ed804`, addendum "the legend is document-level and +deduplicated" + a Deviation Log row + a reworded B4 rule). The old B4 wording ("every footnote is referenced +from the row that owns it") described the pre-#1965 inline-symbol design (`†`/`‡`) and was **wrong** for the +current model. + +Two structurally different note kinds now share the legend block but **not** a numbering scheme: + +- **`*N` skipped-document notes** — numbered, row-owned, one per skipped document, built in `overviewPdf.ts` + at generation time (never in `ReportContent`). B4's "referenced from the owning row" rule applies here only. +- **`content.footnotes[]` legend entries** — **at most one per flag type for the whole document** + (currently 2: `split`, `depositReduced`). `buildReportContent.ts` accumulates `splitInvoiceIds` / + `depositReducedInvoiceIds` as `Set` and pushes gated on `set.size > 0`, so cardinality is + independent of how many rows carry the flag. `marker` is the repeated human-readable inline label + (`partial` / `less deposit`, report-language) that the Allocated Amount cell prints, **not** an identifier + — `id` is the machine key. Row↔legend link is **by repetition of the label**, not by stored reference; + rows carry only `isSplit`/`isDepositReduced` booleans. Storing a footnote index on a row would recreate + B4's second numbering namespace. + +Regression to guard when adding a flag type: emitting one entry per flagged row. Assert +`footnotes.length === N` (never `>= 1`) on a fixture where several rows share a flag. + ### ADR-034 debt (owed, NOT yet written — carry this forward) 1. Add the `dontBreakRows` lesson + the "bound the rendered cell, not a field" rule + both detection recipes. 2. **Minimum-bar rule #1 is wrong**: `table._minWidth <= 515.28` fails on correct code (`_minWidth` is the - widest unbreakable *word*, not the laid-out width). Correct check: `max(horizontalRatio) <= 1`. + widest unbreakable _word_, not the laid-out width). Correct check: `max(horizontalRatio) <= 1`. B2's narrative is fine; the generalized rule was mis-transcribed. 3. Module table drifted twice: add `pageGeometry.ts` (#1939) and `index.ts`; drop "PDF-local formatters" from `shared.ts` (deleted in review round 2) and move "table layout constants" to `pageGeometry.ts`. 4. Override-key list (line 148): drop `attachmentsNote` — unreachable since #1959. 5. Record the fixed 6-or-7 column-count constraint above. + +## ADR-034 legend model, corrected in PR #1979 (wiki `03ed804`) + +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` | + +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 + `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. +- Gated on `splitInvoiceIds.size > 0` / `depositReducedInvoiceIds.size > 0` (`Set` accumulated in the + `includedInvoiceIds`-filtered row loop), so `footnotes.length` is bounded by flag count (2), never row count. +- 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 + `{marker}:{text}` ran them together while the PDF used `${marker}: ${text}`. Fixed in #1979 — + any change to either surface must keep the separator identical. diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 3f2327fbe..b6b21c190 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -406,6 +406,23 @@ Rule: **delete rather than comment.** A write path with no reader and no produce round-3/round-4 confusion started — the code said one thing and the comment said another. When triaging a "keep it as a capability?" question, check for a _producer_ first: no producer => dead, remove it. +## Reinstating a removed producer breaks the negative guards left behind (#1965 / PR #1979) + +The exact inverse of the pattern above, and it bites one story later. #1959 removed the `content.footnotes` +producer and left **negative-only guards plus a prose directive**: `ReportWizardPage.ts` said "NOTHING +populates `content.footnotes` … Never assert a positive count on these", and Scenario 18 asserted +`footnotesBlock/footnoteItems` count 0 + the sentence absent from `main`. #1965 restored the producer with a +3-line push in `buildReportContent.ts` — and silently turned a green 3-viewport E2E scenario red. + +**Review rule for any "reinstate / re-enable X" PR:** `grep` the whole repo (especially `e2e/pages/*` and +spec-file header docstrings) for assertions and _directives_ that pin X's absence. A PR that adds a producer +without inverting those is incomplete, and because `E2E Gates` is `main`-only it merges green into `beta` and +surfaces only at promotion (see [[merge-gate-vs-done-gate]] / the beta-merges-past-red-E2E trap). + +Corollary: a stale POM docstring is worse than a stale code comment — it is an instruction later agents obey. +Inverting the E2E assertion usually also discharges the story's "count occurrences in the rendered DOM" AC, so +it is the same edit, not extra work. + ## Staleness tokens: the `finally` block is the hole (#1946 / PR #1977) A monotonic-token guard (`if (ref.current !== token) return;` in `.then`/`.catch`) does **not** protect a @@ -453,7 +470,7 @@ apart again. Playwright POMs address dialogs by accessible name (`page.getByRole('dialog', { name: 'Discard your edits?' })`, `e2e/pages/ReportWizardPage.ts`), so making a title conditional silently narrows that locator to one branch. -In PR #1977 nothing broke — the only E2E usage opens the modal *after* generation resolved, so the old title +In PR #1977 nothing broke — the only E2E usage opens the modal _after_ generation resolved, so the old title still renders — but the POM docstring now documents the title as unconditional, and the next test that opens the modal mid-generation will fail to find the dialog. **Whenever a PR conditionalizes any modal/heading string, grep `e2e/pages/` for the literal** and flag the locator + docstring as an e2e-test-engineer follow-up. Same @@ -471,3 +488,26 @@ requirements reversal authored as a polish issue is the signature.** Cheap fix: old issue + PO ratification on the new one. Check this whenever a PR deletes user-visible report/document content — and check whether the replacement text preserves _meaning_ (`(abzgl. Abschlag)` lost the footnote's "claimed separately", which is compliance-relevant in a bank-facing document). + +## 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* +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 +current code path, and any test asserting a footnote `
  • ` is asserting a superseded design"). + +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)` + 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. + +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 +lying test title is a complete instruction set for deleting the coverage the PR exists to add — and with +`E2E Gates` main-only, that deletion lands on `beta` silently. It is the same mechanism that produced #1965: +#1959 removed a producer and left comments asserting the removal was permanent. diff --git a/.claude/agent-memory/qa-integration-tester/story-1965-report-legend.md b/.claude/agent-memory/qa-integration-tester/story-1965-report-legend.md new file mode 100644 index 000000000..1745a0005 --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/story-1965-report-legend.md @@ -0,0 +1,68 @@ +--- +name: story-1965-report-legend +description: #1965 — PDF legend footnotes reinstated; locale-aware positive check pattern; goneFootnotes loop tail extension +metadata: + type: project +--- + +Story #1965 reinstated legend sentences for split and depositReduced footnotes in +`buildReportContent.ts` after they were removed in #1959. + +**Key changes (2026-08-03):** + +- `buildReportContent.ts` now pushes `{id:'split', marker:..., text:...}` to `footnotes[]` + when `splitInvoiceIds.size > 0`, and `{id:'depositReduced'...}` when + `depositReducedInvoiceIds.size > 0`. + +**Test pattern learned — locale-aware positive check inside a locale for-loop:** +When adding a positive "this text IS present" assertion inside an `[en, de]` locale for-loop, +use `expected.depositFootnoteText` (locale-specific) rather than a hardcoded English string. +Task instructions may provide the English literal — adapt to `expected.` to avoid +a DE-iteration failure. Adding a field to each entry of the `as const` locale array is the +correct fix. + +**goneFootnotes extension pattern:** +To assert a text was REMOVED from goneFootnotes AND add a positive check it IS now present: + +1. Remove the string from `goneFootnotes` array +2. Add `depositFootnoteText: ''` to the `expected` object for each locale +3. Right after the goneFootnotes inner-for loop, add: + ```ts + const depositFootnoteText = expected.depositFootnoteText; + expect(allStrings.some((s) => s.includes(depositFootnoteText))).toBe(true); + ``` + +**Coverage result:** `buildReportContent.ts` — 100% statements, 97.5% branches (uncovered: +`if (options?.includeCoverLetter ?? false)` null-coalescence truthy path, not reachable from the +new footnote tests). + +**Assertion strengthening round (2026-08-03):** +Three improvements landed in the same PR to tighten #1965 test assertions: + +1. **Weak-to-strong marker assertions:** Replaced `not.toContain('†')` / `not.toContain('‡')` + with `toBe(content.labels.splitNote)` / `toBe(content.labels.depositReducedNote)` — proves the + marker is the actual inline label, not merely "not a symbol". + +2. **Text assertions:** Added `toBe(expected.splitFootnoteText)` / `toBe(expected.depositFootnoteText)` + using the locale-specific values, proving real i18n bundle resolution (not key echo). + +3. **Rendered-surface AC 3.1:** Added `splitFootnoteText` to the locale expected objects and added + `expect(allStrings.some((s) => s.includes(splitFootnoteText))).toBe(true)` after the existing + deposit-reduced rendered-surface check. + +**Fix 3 gotcha — NBSP + testing-library string matcher:** +`ReportContentEditor.test.tsx` stale fixture (`†`/`‡`) updated to use current marker format. +The marker `'less deposit'` (which is `'less deposit'` from locale — NBSP encoded as `\xc2\xa0`) +caused `getByText('less deposit:')` to FAIL because testing-library's `matches()` normalizes the +**element text** (NBSP→space) but compares against the **raw un-normalized matcher string**: +``` +normalizedText === String(matcher) +// 'less deposit:' === 'less deposit:' → FALSE +``` +Fix: use a regex, which IS tested against the already-normalized text: +```ts +expect(screen.getByText(/^less\sdeposit:$/)).toBeInTheDocument(); +``` +The `\s` matches the plain space that NBSP normalizes to. This is a general pattern: whenever a +marker or label contains NBSP (from a locale file), use a regex for the `getByText` assertion, not +a plain string — they will never `===`-match the normalized element text. diff --git a/client/src/components/reports/ReportContentEditor.test.tsx b/client/src/components/reports/ReportContentEditor.test.tsx index 59b6f1b15..6faf7d08b 100644 --- a/client/src/components/reports/ReportContentEditor.test.tsx +++ b/client/src/components/reports/ReportContentEditor.test.tsx @@ -938,20 +938,37 @@ describe('ReportContentEditor — summary rows and footnotes', () => { it('renders each footnote with its unnumbered/shared marker and text, read-only', () => { const content = makeContent({ footnotes: [ - { id: 'split', marker: '†', text: 'Amount shown reflects only the portion allocated.' }, { - id: 'deposit-reduced', - marker: '‡', + id: 'split', + marker: 'partial', + text: 'Amount shown reflects only the portion allocated to this source.', + }, + { + id: 'depositReduced', + marker: 'less deposit', text: 'This position reflects deposits claimed separately.', }, ], }); renderEditor({ content }); - expect(screen.getByText('†:')).toBeInTheDocument(); + expect(screen.getByText('partial:')).toBeInTheDocument(); expect( - screen.getByText(/Amount shown reflects only the portion allocated\./), + screen.getByText(/Amount shown reflects only the portion allocated to this source\./), ).toBeInTheDocument(); - expect(screen.getByText('‡:')).toBeInTheDocument(); + // getByText with a string fails when marker has NBSP: the lib normalizes element text + // (NBSP→space) but does NOT normalize the matcher, so ==='less deposit:' always mismatches. + // Regex is tested against the already-normalized text, so \s matches the collapsed space. + expect(screen.getByText(/^less\sdeposit:$/)).toBeInTheDocument(); + // Whitespace-parity guard (#1965): the
  • 's combined text must be "partial: Amount shown…" — + // marker, a single space, then the note text. jest-dom's toHaveTextContent normalises whitespace + // (including NBSP→space) in the element's textContent before comparing, so this catches a + // missing space (or extra space) between the and the text node without being fragile to + // NBSP, while the independent getByText checks above cannot detect inter-node spacing defects. + // Locate via the already-proven marker and walk up to the enclosing
  • . + const splitLi = screen.getByText('partial:').closest('li') as HTMLElement; + expect(splitLi).toHaveTextContent( + 'partial: Amount shown reflects only the portion allocated to this source.', + ); }); it('renders no footnotes block when footnotes is empty', () => { diff --git a/client/src/components/reports/ReportContentEditor.tsx b/client/src/components/reports/ReportContentEditor.tsx index 5e3505d48..cde6366f6 100644 --- a/client/src/components/reports/ReportContentEditor.tsx +++ b/client/src/components/reports/ReportContentEditor.tsx @@ -458,8 +458,7 @@ export function ReportContentEditor({
      {content.footnotes.map((note) => (
    • - {note.marker}: - {note.text} + {note.marker}: {note.text}
    • ))}
    diff --git a/client/src/lib/reportContent/buildReportContent.test.ts b/client/src/lib/reportContent/buildReportContent.test.ts index 75e7a4eb9..95c6b059e 100644 --- a/client/src/lib/reportContent/buildReportContent.test.ts +++ b/client/src/lib/reportContent/buildReportContent.test.ts @@ -444,72 +444,83 @@ describe('buildReportContent — rows', () => { }); }); -describe('buildReportContent — footnotes (always empty; split/deposit annotations are inline)', () => { - it('produces no footnotes when invoices are split with budget lines', () => { - const inv1 = makeInvoice({ +describe('buildReportContent — footnotes (legend sentences for split/depositReduced)', () => { + it('AC 1.1 — split flag: one split invoice included produces a footnote with id "split" and the correct keys', () => { + const inv = makeInvoice({ invoiceId: 'inv-1', isSplit: true, budgetLines: [makeBudgetLine()], + deposits: [], }); - const inv2 = makeInvoice({ - invoiceId: 'inv-2', - isSplit: true, - budgetLines: [makeBudgetLine()], - }); - const report = makeReport([inv1, inv2]); - const content = buildReportContent(report, new Set(['inv-1', 'inv-2']), 'claim', t, formatters); - expect(content.footnotes).toEqual([]); - expect(content.rows.every((r) => r.isSplit)).toBe(true); - }); - - it('produces no footnotes when all invoices are unsplit', () => { - const report = makeReport([makeInvoice({ isSplit: false })]); + const report = makeReport([inv]); const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); - expect(content.footnotes).toEqual([]); - expect(content.rows[0]!.isSplit).toBe(false); + expect(content.footnotes).toHaveLength(1); + expect(content.footnotes[0]!.id).toBe('split'); + expect(content.footnotes[0]!.marker).toBe('sourceReports.table.splitInlineLabel'); + expect(content.footnotes[0]!.text).toBe('sourceReports.table.splitFootnote'); }); - it('produces no footnotes for constituted (tagged) deposit — the row gets isDeposit instead', () => { - const invoice = makeInvoice({ + it('AC 1.2 — depositReduced flag: one depositReduced invoice produces a footnote with id "depositReduced"', () => { + const inv = makeInvoice({ + invoiceId: 'inv-1', isSplit: true, budgetLines: [], - deposits: [makeDeposit({ budgetSourceId: 'src-1' })], + deposits: [makeDeposit({ budgetSourceId: null })], }); - const report = makeReport([invoice], { id: 'src-1' }); + const report = makeReport([inv], { id: 'src-1' }); const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); - expect(content.footnotes).toEqual([]); - expect(content.rows[0]!.isDeposit).toBe(true); + expect(content.footnotes).toHaveLength(1); + expect(content.footnotes[0]!.id).toBe('depositReduced'); }); - it('produces no footnotes when invoices have reduced (untagged) deposits — isDepositReduced is set instead', () => { - const invoice = makeInvoice({ + it('AC 1.3 — both flags: split and depositReduced present → footnotes length 2, split first, depositReduced second', () => { + const inv = makeInvoice({ + invoiceId: 'inv-1', isSplit: true, - budgetLines: [], + budgetLines: [makeBudgetLine()], deposits: [makeDeposit({ budgetSourceId: null })], }); - const report = makeReport([invoice], { id: 'src-1' }); + const report = makeReport([inv], { id: 'src-1' }); + const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); + expect(content.footnotes).toHaveLength(2); + expect(content.footnotes[0]!.id).toBe('split'); + expect(content.footnotes[1]!.id).toBe('depositReduced'); + }); + + it('AC 1.4 — neither flag: normal invoice → footnotes is empty', () => { + const inv = makeInvoice({ invoiceId: 'inv-1', isSplit: false }); + const report = makeReport([inv]); const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); expect(content.footnotes).toEqual([]); - expect(content.rows[0]!.isDepositReduced).toBe(true); }); - it('produces no footnotes when an invoice has both split lines and a reduced deposit', () => { - const combined = makeInvoice({ + it('AC 1.5 — deduplication: two split invoices produce only one footnote entry', () => { + const inv1 = makeInvoice({ invoiceId: 'inv-1', isSplit: true, budgetLines: [makeBudgetLine()], - deposits: [makeDeposit({ budgetSourceId: null })], }); - const report = makeReport([combined], { id: 'src-1' }); - const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); - expect(content.footnotes).toEqual([]); - expect(content.rows[0]!.isSplit).toBe(true); - expect(content.rows[0]!.isDepositReduced).toBe(true); + const inv2 = makeInvoice({ + invoiceId: 'inv-2', + isSplit: true, + budgetLines: [makeBudgetLine()], + }); + const report = makeReport([inv1, inv2]); + const content = buildReportContent(report, new Set(['inv-1', 'inv-2']), 'claim', t, formatters); + expect(content.footnotes).toHaveLength(1); + expect(content.footnotes[0]!.id).toBe('split'); }); - it('produces no footnotes when no invoice is split or has a reduced deposit', () => { - const report = makeReport([makeInvoice()]); - const content = buildReportContent(report, new Set(['inv-1']), 'claim', t, formatters); + it('excluded split invoice does not contribute to footnotes', () => { + const excluded = makeInvoice({ + invoiceId: 'inv-excluded', + isSplit: true, + budgetLines: [makeBudgetLine()], + deposits: [makeDeposit({ budgetSourceId: null })], + }); + const included = makeInvoice({ invoiceId: 'inv-included', isSplit: false }); + const report = makeReport([excluded, included], { id: 'src-1' }); + const content = buildReportContent(report, new Set(['inv-included']), 'claim', t, formatters); expect(content.footnotes).toEqual([]); }); }); diff --git a/client/src/lib/reportContent/buildReportContent.ts b/client/src/lib/reportContent/buildReportContent.ts index 811da696a..77f79a60d 100644 --- a/client/src/lib/reportContent/buildReportContent.ts +++ b/client/src/lib/reportContent/buildReportContent.ts @@ -231,6 +231,22 @@ export function buildReportContent( const footnotes: ReportContentFootnote[] = []; + // Legend footnotes: one sentence per flag, deduplicated by set membership (AC 1.1–1.5) + if (splitInvoiceIds.size > 0) { + footnotes.push({ + id: 'split', + marker: reportT('sourceReports.table.splitInlineLabel'), + text: reportT('sourceReports.table.splitFootnote'), + }); + } + if (depositReducedInvoiceIds.size > 0) { + footnotes.push({ + id: 'depositReduced', + marker: reportT('sourceReports.table.depositReducedInlineLabel'), + text: reportT('sourceReports.table.depositReducedFootnote'), + }); + } + // Build cover letter (if enabled) let coverLetter: ReportContentCoverLetter | null = null; if (includeCoverLetter) { diff --git a/client/src/lib/reportPdf/realRender.test.ts b/client/src/lib/reportPdf/realRender.test.ts index ac155f940..613ec0409 100644 --- a/client/src/lib/reportPdf/realRender.test.ts +++ b/client/src/lib/reportPdf/realRender.test.ts @@ -976,10 +976,9 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { depositReducedLabel: ' (less\u00A0deposit)', splitLabel: ' (partial)', // The footnote WORDINGS that must no longer appear anywhere in the tree. - goneFootnotes: [ - 'This position reflects deposits claimed separately.', - 'This is a deposit', - ], + goneFootnotes: ['This is a deposit'], + depositFootnoteText: 'This position reflects deposits claimed separately.', + splitFootnoteText: 'Amount shown reflects only the portion allocated to this source.', }, ], [ @@ -991,10 +990,11 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { // DE wrapped as "(Teilbetrag) (abzgl." / "Abschlag)" before the fix. depositReducedLabel: ' (abzgl.\u00A0Abschlag)', splitLabel: ' (Teilbetrag)', - goneFootnotes: [ + goneFootnotes: ['Dies ist eine Abschlagszahlung'], + depositFootnoteText: 'Diese Position berücksichtigt separat eingereichte Abschlagszahlungen.', - 'Dies ist eine Abschlagszahlung', - ], + splitFootnoteText: + 'Der angezeigte Betrag umfasst nur den dieser Quelle zugeordneten Anteil.', }, ], ] as const) { @@ -1011,8 +1011,16 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { const reducedRow = content.rows.find((r) => r.invoiceId === 'inv-deposit-reduced')!; expect(reducedRow.isDeposit).toBe(false); expect(reducedRow.isDepositReduced).toBe(true); - // #1959: the footnote array is empty — the annotations no longer live there at all. - expect(content.footnotes).toEqual([]); + // #1965: legend sentences are reinstated — split and depositReduced each produce one entry. + expect(content.footnotes).toHaveLength(2); + expect(content.footnotes[0]!.id).toBe('split'); + expect(content.footnotes[1]!.id).toBe('depositReduced'); + // Markers match the inline labels (AC 2.3) — shared token between legend and row cell + expect(content.footnotes[0]!.marker).toBe(content.labels.splitNote); + expect(content.footnotes[1]!.marker).toBe(content.labels.depositReducedNote); + // Text values resolve from real i18n bundles, not echoed keys (real render, not mock t) + expect(content.footnotes[0]!.text).toBe(expected.splitFootnoteText); + expect(content.footnotes[1]!.text).toBe(expected.depositFootnoteText); const pdfContent = buildOverviewContent(content, new Map(), t); const tableItem = pdfContent.find( @@ -1050,6 +1058,12 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { for (const gone of expected.goneFootnotes) { expect(allStrings.some((s) => s.includes(gone))).toBe(false); } + // #1965: the deposit-reduced legend sentence IS present in the rendered content. + const depositFootnoteText = expected.depositFootnoteText; + expect(allStrings.some((s) => s.includes(depositFootnoteText))).toBe(true); + // #1965 AC 3.1: split legend sentence also present in the rendered content tree + const splitFootnoteText = expected.splitFootnoteText; + expect(allStrings.some((s) => s.includes(splitFootnoteText))).toBe(true); } }); diff --git a/e2e/pages/ReportWizardPage.ts b/e2e/pages/ReportWizardPage.ts index 1c013003c..04c73601f 100644 --- a/e2e/pages/ReportWizardPage.ts +++ b/e2e/pages/ReportWizardPage.ts @@ -205,8 +205,8 @@ * `budget-overview`/`proof-of-funds` reports are unaffected (still render it). * - The `†` (split) / `‡` (deposit-reduced) markers this story made shared/unnumbered were * REPLACED WHOLESALE by inline labels in Issue #1959 — see that paragraph below. Neither glyph - * appears anywhere in the UI or the PDF any more, and `footnotesBlock`/`footnoteItems` - * (declared above) consequently have no producer left. + * appears anywhere in the UI or the PDF any more. `footnotesBlock`/`footnoteItems` had no + * producer at this point, but see Issue #1965 below — that changed. * - A constituted-deposit row (the allocation is made up entirely by a deposit tagged to the * reported source) carries NO marker/label at all — instead an inline `Badge` (`depositBadge`/ * `mobileDepositBadge` below) reading "Deposit"/"Abschlagszahlung". There is correspondingly @@ -220,11 +220,18 @@ * `mobileUsageMetaText()` below (Issue #1959). * * Issue #1959: report PDF/UI polish — inline meta, inline split/deposit labels, column toggles. - * - `†`/`‡` markers and the footnote LIST are GONE. `buildReportContent.ts` no longer pushes any - * `ReportContentFootnote` at all (`ReportContentRow.allocatedMarkers` was replaced by - * `isSplit`/`isDepositReduced` booleans), so `footnotesBlock`/`footnoteItems` are retained - * below purely as negative guards — they can never be populated by the current code path, and - * any test asserting a marker glyph or footnote `
  • ` is asserting a superseded design. + * - `†`/`‡` markers and the footnote LIST are GONE at this point. `buildReportContent.ts` no + * longer pushes any `ReportContentFootnote` (`ReportContentRow.allocatedMarkers` was replaced by + * `isSplit`/`isDepositReduced` booleans). `footnotesBlock`/`footnoteItems` were retained as + * negative guards only — until Issue #1965 reinstated the legend (see below). + * + * Issue #1965: report PDF legend — `buildReportContent.ts` now pushes legend entries for rows + * where `isSplit` or `isDepositReduced` is true, so `footnotesBlock`/`footnoteItems` ARE now + * populated in split/deposit-reduced scenarios. `footnotesBlock` contains the legend block (or + * is absent from the DOM entirely when no split/deposit-reduced rows appear); `footnoteItems` + * are the `
  • ` elements inside it, one per deduplicated flag type (so two split invoices + * produce exactly one `
  • ` entry, not two). Tests asserting `toHaveCount(1)` on these + * locators reflect the current design and are correct. * Instead, a split row appends a grey inline `` reading `(partial)` * (de: `(Teilbetrag)`, `sourceReports.table.splitInlineLabel`) INSIDE the Allocated Amount * cell, and a deposit-reduced row one reading `(less deposit)` (de: `(abzgl. Abschlag)`, @@ -469,11 +476,14 @@ export class ReportWizardPage { // avoids a substring collision). readonly summaryTable: Locator; readonly summaryTableRows: Locator; - // The footnotes block (`.footnotes` / its `
  • ` entries). As of Issue #1959 NOTHING populates - // `content.footnotes` any more (the `†`/`‡` split + deposit-reduced entries became inline - // labels — see `inlineNote()`), so the block is never rendered. Kept purely as a NEGATIVE - // guard: a scenario asserting `toHaveCount(0)` / `not.toBeVisible()` here is asserting that - // the superseded footnote mechanism has not come back. Never assert a positive count on these. + // The footnotes block (`.footnotes` / its `
  • ` entries). Issue #1959 replaced the `†`/`‡` + // split + deposit-reduced glyphs with inline labels (see `inlineNote()`), but Issue #1965 + // restored legend population: `buildReportContent.ts` now pushes ONE deduplicated sentence per + // active flag (`isSplit` → "Amount shown reflects only the portion allocated to this source.", + // `isDepositReduced` → the corresponding deposit-reduced sentence) whenever any row in the + // report carries that flag. Scenarios with split or deposit-reduced rows must assert a positive + // count; scenarios with neither (e.g., constituted-deposit-only rows — Scenario 17) correctly + // assert `toHaveCount(0)`. readonly footnotesBlock: Locator; readonly footnoteItems: Locator; diff --git a/e2e/tests/budget/reportWizardEditableContent.spec.ts b/e2e/tests/budget/reportWizardEditableContent.spec.ts index 1fb615534..0fcaddb96 100644 --- a/e2e/tests/budget/reportWizardEditableContent.spec.ts +++ b/e2e/tests/budget/reportWizardEditableContent.spec.ts @@ -75,8 +75,9 @@ * attachments note into ONE read-only grey meta line in the Usage cell. See `ReportWizardPage.ts`'s * class docstring for the full locator reference (`sourceInfoBlock`, `depositBadge`/ * `mobileDepositBadge`, `inlineNote`/`mobileInlineNote`, `usageMetaText`/`mobileUsageMetaText`, - * `summaryTable`/`summaryTableRows`, and `footnotesBlock`/`footnoteItems` — retained as - * negative-only guards now that nothing populates `content.footnotes`). + * `summaryTable`/`summaryTableRows`, and `footnotesBlock`/`footnoteItems` — zero for + * constituted-deposit-only scenarios (Scenario 17), one deduplicated entry for split/ + * deposit-reduced scenarios (Issue #1965)). * - Scenario 16: A `claim` report omits the source-info metadata block entirely (AC3.1) — the * counterpart to Scenario 1's `budget-overview` regression guard (AC3.3). * - Scenario 17: A constituted-deposit row (the row's allocation is made up entirely by a @@ -84,9 +85,10 @@ * desktop, tablet, AND mobile, carries NO inline `(partial)`/`(less deposit)` note (nor either * legacy `†`/`‡` glyph), and there is no footnotes block at all (AC2.1, AC2.2). * - Scenario 18: Every split invoice carries its OWN inline `(partial)` label in its Allocated - * Amount cell (Issue #1959, superseding AC1.1-AC1.2's shared unnumbered `†` marker), and the - * footnote list — including the long-form "Amount shown reflects only the portion allocated to - * this source." sentence — is gone from the page entirely. + * Amount cell (Issue #1959, superseding AC1.1-AC1.2's shared unnumbered `†` marker). The + * footnote list now contains exactly ONE deduplicated legend sentence ("Amount shown reflects + * only the portion allocated to this source.") pushed by `buildReportContent.ts` because + * `splitInvoiceIds.size > 0` (Issue #1965). * - Scenario 19: Invoices spanning two or more statuses still produce exactly one summary row * (`Total`) — no per-status subtotal rows (AC4.1-AC4.2). * - Scenario 20: A budget line linked to an item with an assigned area shows the item's leaf @@ -1620,11 +1622,13 @@ test.describe( expect(rowText).not.toContain('‡'); } - // No footnote entry at all — no split (B has zero budget lines here), no - // deposit-reduced (the deposit IS tagged to B, not "reduced"), the removed - // `depositConstitutedFootnote` key ("This is a deposit.") never had a footnote to begin - // with even before Story #1923, and as of Issue #1959 nothing populates - // `content.footnotes` at all any more. + // No footnote entry: this is a constituted-deposit row — the allocation is made up + // entirely by a deposit tagged to source B, so `isSplit` is false (only source B has + // budget lines; the invoice is not split across sources), and `isDepositReduced` is also + // false (the deposit constitutes the row, it does not reduce a gross amount). + // `buildReportContent.ts` only pushes legend entries when `splitInvoiceIds.size > 0` or + // `depositReducedInvoiceIds.size > 0` — neither condition holds here, so + // `content.footnotes` stays empty and the block is absent from the DOM (Issue #1965). await expect(wizard.footnotesBlock).toHaveCount(0); } finally { if (workItemId) await deleteWorkItemViaApi(page, workItemId); @@ -1637,12 +1641,12 @@ test.describe( ); // ───────────────────────────────────────────────────────────────────────────── -// Scenario 18: Every split invoice carries its own inline "(partial)" label and there is no -// footnote list at all (Story #1923 AC1, superseded by Issue #1959) +// Scenario 18: Split invoices carry an inline "(partial)" label AND produce one deduplicated +// legend entry in the footnotes block (Issue #1965) // ───────────────────────────────────────────────────────────────────────────── test.describe('Report wizard editable content — inline split label (Scenario 18)', () => { - test('Two split invoices each show a grey inline "(partial)" note in their Allocated Amount cell, with no †/‡ marker and no footnote list anywhere on the page', async ({ + test('Two split invoices each show a grey inline "(partial)" note in their Allocated Amount cell, and produce exactly one deduplicated legend entry in the footnotes block', async ({ page, testPrefix, }) => { @@ -1710,22 +1714,25 @@ test.describe('Report wizard editable content — inline split label (Scenario 1 await expect(row1).toContainText('€100.00 (partial)'); await expect(row2).toContainText('€150.00 (partial)'); - // Negatives, each paired with the positives above: neither footnote glyph survives - // anywhere in the row (numbered or not), and the footnote list has no producer left — the - // block is absent from the DOM entirely rather than merely empty. + // Negatives, each paired with the positives above: neither legacy footnote glyph survives + // anywhere in the row (numbered or not). const row1Text = (await row1.textContent()) ?? ''; const row2Text = (await row2.textContent()) ?? ''; expect(row1Text).not.toContain('†'); expect(row2Text).not.toContain('†'); expect(row1Text).not.toContain('‡'); expect(row2Text).not.toContain('‡'); - await expect(wizard.footnotesBlock).toHaveCount(0); - await expect(wizard.footnoteItems).toHaveCount(0); - // The long-form footnote sentence the marker used to point at is gone from the page too - // (it now only exists as the short inline label asserted above). + // Issue #1965: `buildReportContent.ts` now pushes ONE deduplicated legend entry to + // `content.footnotes` whenever `splitInvoiceIds.size > 0`. Both invoices in this fixture + // are split, so the block must be present with exactly 1 item — deduped even though two + // rows triggered it. + await expect(wizard.footnotesBlock).toHaveCount(1); + await expect(wizard.footnoteItems).toHaveCount(1); + + // The long-form legend sentence must be present in the footnote list. const pageText = (await page.locator('main').textContent()) ?? ''; - expect(pageText).not.toContain( + expect(pageText).toContain( 'Amount shown reflects only the portion allocated to this source.', ); expect(pageText).toContain('(partial)'); diff --git a/wiki b/wiki index df8a462fc..f9cd21c8d 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit df8a462fcfef88610781a49258fdccf2f92d45ee +Subproject commit f9cd21c8db3f98b1315aeadaa0287ce5ae241cfc From 0146afb6a3a0865ec0b877a7171959b2fa8f8d7d Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 08:42:10 +0200 Subject: [PATCH 04/42] fix(data-table): surface failed column-preference saves as error toast; remove dead isLoaded API (#1972) - Failed PATCH calls from the column-settings popover now surface an error toast instead of silently dropping the failure - Local column state is preserved on error so a subsequent toggle carries the full intended set - Removed dead `isLoaded` boolean from `useColumnPreferences` (maintained but never consumed by any production code) - Added `ToastProvider` wrapper to eight page-level test render helpers that were missing it after `useColumnPreferences` gained a context dependency Fixes #1972 Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude qa-integration-tester Co-Authored-By: Claude translator --- .../components/DataTable/DataTable.test.tsx | 1 - client/src/hooks/useColumnPreferences.test.ts | 40 +++++++++++++++++-- client/src/hooks/useColumnPreferences.ts | 15 +++---- client/src/i18n/de/common.json | 3 +- client/src/i18n/en/common.json | 3 +- .../HouseholdItemsPage.breadcrumb.test.tsx | 9 +++-- .../HouseholdItemsPage.test.tsx | 9 +++-- .../pages/InvoicesPage/InvoicesPage.test.tsx | 37 +++++++++-------- .../MilestonesPage/MilestonesPage.test.tsx | 9 +++-- .../UserManagementPage.test.tsx | 9 +++-- .../pages/VendorsPage/VendorsPage.test.tsx | 9 +++-- .../WorkItemsPage/WorkItemsPage.test.tsx | 9 +++-- 12 files changed, 103 insertions(+), 50 deletions(-) diff --git a/client/src/components/DataTable/DataTable.test.tsx b/client/src/components/DataTable/DataTable.test.tsx index cbf1542b2..3f6e62636 100644 --- a/client/src/components/DataTable/DataTable.test.tsx +++ b/client/src/components/DataTable/DataTable.test.tsx @@ -98,7 +98,6 @@ beforeEach(async () => { mockUseColumnPreferences.mockReturnValue({ visibleColumns: new Set(COLUMNS.map((c) => c.key)), columnOrder: COLUMNS.map((c) => c.key), - isLoaded: true, toggleColumn: mockToggleColumn, moveColumn: mockMoveColumn, resetToDefaults: mockResetToDefaults, diff --git a/client/src/hooks/useColumnPreferences.test.ts b/client/src/hooks/useColumnPreferences.test.ts index 09e77e959..d64b2e57f 100644 --- a/client/src/hooks/useColumnPreferences.test.ts +++ b/client/src/hooks/useColumnPreferences.test.ts @@ -11,6 +11,11 @@ jest.unstable_mockModule('./usePreferences.js', () => ({ usePreferences: mockUsePreferences, })); +const mockShowToast = jest.fn(); +jest.unstable_mockModule('../components/Toast/ToastContext.js', () => ({ + useToast: () => ({ showToast: mockShowToast, dismissToast: jest.fn(), toasts: [] }), +})); + import type * as UseColumnPreferencesModule from './useColumnPreferences.js'; let useColumnPreferences: (typeof UseColumnPreferencesModule)['useColumnPreferences']; @@ -46,6 +51,7 @@ beforeEach(async () => { (await import('./useColumnPreferences.js')) as typeof UseColumnPreferencesModule); mockUsePreferences.mockReset(); mockUpsert.mockReset(); + mockShowToast.mockReset(); mockUsePreferences.mockReturnValue(makeUsePreferencesResult()); mockUpsert.mockResolvedValue(undefined); }); @@ -80,10 +86,6 @@ describe('useColumnPreferences', () => { expect(result.current.visibleColumns.has('hidden')).toBe(false); }); - it('returns isLoaded=true immediately (preferences available synchronously)', () => { - const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS)); - expect(result.current.isLoaded).toBe(true); - }); }); describe('loading from preferences', () => { @@ -211,6 +213,7 @@ describe('useColumnPreferences', () => { order: string[]; }; expect(savedValue.visible).toContain('id'); + expect(mockShowToast).not.toHaveBeenCalled(); }); }); @@ -652,6 +655,32 @@ describe('useColumnPreferences', () => { expect(view.result.current.visibleColumns.has('title')).toBe(false); }); + it('shows an error toast when a write fails and preserves local column state', async () => { + const view = renderRaceHook(); + + act(() => { + view.result.current.toggleColumn('colA'); + }); + + // Let the debounce fire to dispatch the write. + await act(async () => { + jest.advanceTimersByTime(500); + }); + expect(upsertCalls).toHaveLength(1); + + // Fail the write. + await act(async () => { + writes[0]!.reject(new Error('network error')); + }); + + // Toast must fire exactly once with the error severity. + expect(mockShowToast).toHaveBeenCalledTimes(1); + expect(mockShowToast).toHaveBeenCalledWith('error', expect.any(String)); + + // Local column state must not be reverted — the UI stays consistent. + expect(view.result.current.visibleColumns.has('colA')).toBe(true); + }); + it('does not wedge the save queue when a write fails', async () => { const unhandled: unknown[] = []; const onUnhandled = (reason: unknown) => { @@ -682,6 +711,9 @@ describe('useColumnPreferences', () => { writes[0]!.reject(new Error('network down')); }); + // A toast must fire to inform the user of the save error. + expect(mockShowToast).toHaveBeenCalledWith('error', expect.any(String)); + // The queue drains anyway: the newest payload still goes out. expect(upsertCalls).toHaveLength(2); const second = parsePayload(upsertCalls[1]!.value); diff --git a/client/src/hooks/useColumnPreferences.ts b/client/src/hooks/useColumnPreferences.ts index b083a2aad..a0a776036 100644 --- a/client/src/hooks/useColumnPreferences.ts +++ b/client/src/hooks/useColumnPreferences.ts @@ -1,11 +1,12 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import type { ColumnDef } from '../components/DataTable/DataTable.js'; +import { useToast } from '../components/Toast/ToastContext.js'; import { usePreferences } from './usePreferences.js'; export interface UseColumnPreferencesResult { visibleColumns: Set; columnOrder: string[]; - isLoaded: boolean; toggleColumn: (key: string) => void; moveColumn: (from: number, to: number) => void; resetToDefaults: () => void; @@ -28,6 +29,8 @@ export function useColumnPreferences( ): UseColumnPreferencesResult { const preferenceKey = `table.${pageKey}.columns`; const { preferences, upsert } = usePreferences(); + const { t } = useTranslation('common'); + const { showToast } = useToast(); const defaultColumnOrder = columns.map((col) => col.key); const defaultVisibleColumns = new Set( @@ -37,7 +40,6 @@ export function useColumnPreferences( const [visibleColumns, setVisibleColumns] = useState>(defaultVisibleColumns); const [columnOrder, setColumnOrder] = useState(defaultColumnOrder); - const [isLoaded, setIsLoaded] = useState(false); const saveDebounceRef = useRef(null); // Which preference key this hook instance has taken local ownership of. @@ -99,9 +101,6 @@ export function useColumnPreferences( // If JSON parse fails, use defaults } } - /* eslint-disable @eslint-react/set-state-in-effect -- loading and initializing column state from stored preferences */ - setIsLoaded(true); - /* eslint-enable @eslint-react/set-state-in-effect */ // eslint-disable-next-line @eslint-react/exhaustive-deps -- defaultColumnOrder is derived from columns each render; the load effect runs on preferences change only }, [preferences, preferenceKey]); @@ -125,14 +124,13 @@ export function useColumnPreferences( try { await upsert(preferenceKey, JSON.stringify(payload)); } catch { - // Write failed: drop it, matching the previous `void upsert(...)` behaviour. - // `usePreferences` surfaces the error; retry is out of scope. + showToast('error', t('dataTable.columnSettings.saveError')); } } } finally { isSavingRef.current = false; } - }, [preferenceKey, upsert]); + }, [preferenceKey, upsert, showToast, t]); const savePreferences = useCallback( (newVisible: Set, newOrder: string[]) => { @@ -192,7 +190,6 @@ export function useColumnPreferences( return { visibleColumns, columnOrder, - isLoaded, toggleColumn, moveColumn, resetToDefaults, diff --git a/client/src/i18n/de/common.json b/client/src/i18n/de/common.json index 16fd4b7bc..61663e5d0 100644 --- a/client/src/i18n/de/common.json +++ b/client/src/i18n/de/common.json @@ -154,7 +154,8 @@ "ariaLabel": "Spalteneinstellungen", "title": "Sichtbare Spalten", "resetToDefaults": "Standardeinstellungen wiederherstellen", - "dragHandleAriaLabel": "{{column}}-Spalte zum Neuanordnen ziehen" + "dragHandleAriaLabel": "{{column}}-Spalte zum Neuanordnen ziehen", + "saveError": "Spalteneinstellungen konnten nicht gespeichert werden" }, "pagination": { "showing": "Zeige {{from}}–{{to}} von {{total}} Einträgen", diff --git a/client/src/i18n/en/common.json b/client/src/i18n/en/common.json index 5b1758845..0b546ef7b 100644 --- a/client/src/i18n/en/common.json +++ b/client/src/i18n/en/common.json @@ -154,7 +154,8 @@ "ariaLabel": "Column settings", "title": "Visible columns", "resetToDefaults": "Reset to defaults", - "dragHandleAriaLabel": "Drag to reorder {{column}} column" + "dragHandleAriaLabel": "Drag to reorder {{column}} column", + "saveError": "Failed to save column settings" }, "pagination": { "showing": "Showing {{from}}–{{to}} of {{total}} items", diff --git a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.breadcrumb.test.tsx b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.breadcrumb.test.tsx index ecc08aa0a..c4889ef7f 100644 --- a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.breadcrumb.test.tsx +++ b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.breadcrumb.test.tsx @@ -12,6 +12,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { screen, waitFor, render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type * as HouseholdItemsApiTypes from '../../lib/householdItemsApi.js'; import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; import type * as HouseholdItemCategoriesApiTypes from '../../lib/householdItemCategoriesApi.js'; @@ -161,9 +162,11 @@ let HouseholdItemsPage: any; function renderPage() { return render( - - - , + + + + + , ); } diff --git a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx index 3f993e88f..794d8c7ce 100644 --- a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx +++ b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx @@ -8,6 +8,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { screen, waitFor, render, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type * as HouseholdItemsApiTypes from '../../lib/householdItemsApi.js'; import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; import type * as HouseholdItemCategoriesApiTypes from '../../lib/householdItemCategoriesApi.js'; @@ -158,9 +159,11 @@ let HouseholdItemsPage: any; function renderPage() { return render( - - - , + + + + + , ); } diff --git a/client/src/pages/InvoicesPage/InvoicesPage.test.tsx b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx index fd32c1c7f..a14e45336 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.test.tsx +++ b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx @@ -5,6 +5,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import { ApiClientError } from '../../lib/apiClient.js'; import type * as InvoicesApiTypes from '../../lib/invoicesApi.js'; import type { Invoice, InvoiceListPaginatedResponse } from '@cornerstone/shared'; @@ -290,14 +291,16 @@ describe('InvoicesPage', () => { function renderPage() { return render( - - - } /> - Invoice Detail} /> - Vendor Detail} /> - - - , + + + + } /> + Invoice Detail} /> + Vendor Detail} /> + + + + , ); } @@ -1037,14 +1040,16 @@ describe('InvoicesPage', () => { function renderPageWithCreate() { return render( - - - } /> - Invoice Detail} /> - Vendor Detail} /> - - - , + + + + } /> + Invoice Detail} /> + Vendor Detail} /> + + + + , ); } diff --git a/client/src/pages/MilestonesPage/MilestonesPage.test.tsx b/client/src/pages/MilestonesPage/MilestonesPage.test.tsx index afd2678f1..c0e25e4c0 100644 --- a/client/src/pages/MilestonesPage/MilestonesPage.test.tsx +++ b/client/src/pages/MilestonesPage/MilestonesPage.test.tsx @@ -4,6 +4,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type * as MilestonesApiTypes from '../../lib/milestonesApi.js'; import { ApiClientError } from '../../lib/apiClient.js'; import type { MilestoneSummary } from '@cornerstone/shared'; @@ -121,9 +122,11 @@ describe('MilestonesPage', () => { function renderPage() { return render( - - - , + + + + + , ); } diff --git a/client/src/pages/UserManagementPage/UserManagementPage.test.tsx b/client/src/pages/UserManagementPage/UserManagementPage.test.tsx index 411592214..f2cb815a8 100644 --- a/client/src/pages/UserManagementPage/UserManagementPage.test.tsx +++ b/client/src/pages/UserManagementPage/UserManagementPage.test.tsx @@ -8,6 +8,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { screen, waitFor, render, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type { ReactNode } from 'react'; import type * as UsersApiTypes from '../../lib/usersApi.js'; import type * as AuthContextTypes from '../../contexts/AuthContext.js'; @@ -83,9 +84,11 @@ let UserManagementPage: any; function renderPage() { return render( - - - , + + + + + , ); } diff --git a/client/src/pages/VendorsPage/VendorsPage.test.tsx b/client/src/pages/VendorsPage/VendorsPage.test.tsx index fde8709d4..65313acc2 100644 --- a/client/src/pages/VendorsPage/VendorsPage.test.tsx +++ b/client/src/pages/VendorsPage/VendorsPage.test.tsx @@ -8,6 +8,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { screen, waitFor, render, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type { ReactNode } from 'react'; import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; import type * as UseTradesTypes from '../../hooks/useTrades.js'; @@ -153,9 +154,11 @@ let VendorsPage: any; function renderPage() { return render( - - - , + + + + + , ); } diff --git a/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx b/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx index 3994b2478..e5b6fecaa 100644 --- a/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx +++ b/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx @@ -4,6 +4,7 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../../components/Toast/ToastContext.js'; import type { WorkItemSummary } from '@cornerstone/shared'; import type * as WorkItemsApiTypes from '../../lib/workItemsApi.js'; import type * as UsersApiTypes from '../../lib/usersApi.js'; @@ -172,9 +173,11 @@ describe('WorkItemsPage', () => { function renderPage() { return render( - - - , + + + + + , ); } From ed2b7b3ad408d5bc8911030ae72873500dd403ac Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 09:24:12 +0200 Subject: [PATCH 05/42] fix(reports): running header timestamp and German word-break (#1937, #1938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pages 2+ of generated report PDFs now show the complete generated-at line with label and timestamp; previously the timestamp was blank on every multi-page report (#1938) - German table header labels "Auftragnehmer" (67.5pt > 45pt column) and "Rechnungsbetrag" (78.7pt > 48pt column) now shortened to "Firma" and "Betrag" — eliminating mid-word breaks on every German-locale report (#1937) Fixes #1938 Fixes #1937 Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester Co-Authored-By: Claude translator --- .../agent-memory/product-architect/MEMORY.md | 2 +- .../product-architect/client-pdf-pipeline.md | 52 ++++++++++-- .../product-architect/recurring-patterns.md | 25 +++++- .../product-architect/story-reviews.md | 23 ++++++ .../pr-1937-1938-pdf-header-labels.md | 57 +++++++++++++ .claude/agent-memory/translator/MEMORY.md | 11 +++ client/src/i18n/de/budget.json | 4 +- client/src/lib/reportPdf/merge.test.ts | 6 +- client/src/lib/reportPdf/merge.ts | 2 +- client/src/lib/reportPdf/realRender.test.ts | 79 ++++++++++++++++--- 10 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 .claude/agent-memory/qa-integration-tester/pr-1937-1938-pdf-header-labels.md diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 96cac9dfa..a4d656cae 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -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) diff --git a/.claude/agent-memory/product-architect/client-pdf-pipeline.md b/.claude/agent-memory/product-architect/client-pdf-pipeline.md index c44827a07..1268cbbcc 100644 --- a/.claude/agent-memory/product-architect/client-pdf-pipeline.md +++ b/.claude/agent-memory/product-architect/client-pdf-pipeline.md @@ -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. @@ -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 `{marker}:{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` (`` 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. diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index b6b21c190..6384e2f90 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -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 @@ -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 @@ -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 diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index a9c5d70dd..3ec67f91c 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -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. diff --git a/.claude/agent-memory/qa-integration-tester/pr-1937-1938-pdf-header-labels.md b/.claude/agent-memory/qa-integration-tester/pr-1937-1938-pdf-header-labels.md new file mode 100644 index 000000000..11f42e3ed --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/pr-1937-1938-pdf-header-labels.md @@ -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. diff --git a/.claude/agent-memory/translator/MEMORY.md b/.claude/agent-memory/translator/MEMORY.md index 667997067..87be77e38 100644 --- a/.claude/agent-memory/translator/MEMORY.md +++ b/.claude/agent-memory/translator/MEMORY.md @@ -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. diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json index 559ef39b5..834e9fc5d 100644 --- a/client/src/i18n/de/budget.json +++ b/client/src/i18n/de/budget.json @@ -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)", diff --git a/client/src/lib/reportPdf/merge.test.ts b/client/src/lib/reportPdf/merge.test.ts index cca87e684..8a1edb978 100644 --- a/client/src/lib/reportPdf/merge.test.ts +++ b/client/src/lib/reportPdf/merge.test.ts @@ -680,7 +680,7 @@ describe('generateReportPdf', () => { expect(result.blob).toBeInstanceOf(Blob); }); - it('pdfmake header callback omits the header on page 1, renders it on subsequent pages, and reads title/sourceName from reportContent', async () => { + it('pdfmake header callback omits the header on page 1, renders it on subsequent pages, and reads title/sourceName from reportContent including the generatedAt value', async () => { const invoice = makeInvoice(); const report = makeReport([invoice]); const content = makeContent({ @@ -705,10 +705,12 @@ describe('generateReportPdf', () => { const sharedModule = (await import('./shared.js')) as unknown as { buildPageHeader: jest.Mock; }; + // #1938: the third arg is "label: value", not the bare i18n key — the label cannot silently + // lose its value again. labels.generatedAt='Generated At', generatedAtText='01/15/2026'. expect(sharedModule.buildPageHeader).toHaveBeenCalledWith( 'My Title', 'My Source', - 'sourceReports.table.generatedAt', + 'Generated At: 01/15/2026', ); // [regression #1929] On current beta this is the hardcoded [40, 40, 40, 60] — the top margin diff --git a/client/src/lib/reportPdf/merge.ts b/client/src/lib/reportPdf/merge.ts index a57a2fc99..6d42ec1d0 100644 --- a/client/src/lib/reportPdf/merge.ts +++ b/client/src/lib/reportPdf/merge.ts @@ -128,7 +128,7 @@ export async function generateReportPdf( return buildPageHeader( reportContent.tableTitle, reportContent.sourceInfo.sourceName, - t('sourceReports.table.generatedAt'), + `${reportContent.labels.generatedAt}: ${reportContent.sourceInfo.generatedAtText}`, ); }, footer: buildPageFooter(t('sourceReports.table.pageLabel')), diff --git a/client/src/lib/reportPdf/realRender.test.ts b/client/src/lib/reportPdf/realRender.test.ts index 613ec0409..e203bf75e 100644 --- a/client/src/lib/reportPdf/realRender.test.ts +++ b/client/src/lib/reportPdf/realRender.test.ts @@ -1958,7 +1958,7 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { return { headerRow: tableItem.table.body[0]! }; } - it('[HIGH1] "Auftragnehmer" (vendor header, real German) and "Rechnungsbetrag" (invoiceAmount header) render without throwing, full text recoverable, and both genuinely wrap to multiple lines (their real measured widths — 67.50pt/78.66pt — exceed their 45pt/48pt columns even at real, not just worst-case, glyph metrics)', async () => { + it('[HIGH1] "Firma" (vendor header, real German #1937 fix) and "Betrag" (invoiceAmount header) render without throwing, full text recoverable, and both fit their columns in a single rendered line', async () => { const { headerRow } = await renderGermanHeaderRow('budget-overview'); const vendorHeader = headerRow[0] as { text: unknown; positions?: { pageNumber: number }[] }; const invoiceAmountHeader = headerRow[4] as { @@ -1966,16 +1966,18 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { positions?: { pageNumber: number }[]; }; - expect(usageCellText(vendorHeader.text)).toBe('Auftragnehmer'); - expect(usageCellText(invoiceAmountHeader.text)).toBe('Rechnungsbetrag'); + // #1937: DE labels changed from "Auftragnehmer"/"Rechnungsbetrag" (too wide) to + // "Firma"/"Betrag" (fit their 45pt/48pt columns at real Roboto glyph metrics). + expect(usageCellText(vendorHeader.text)).toBe('Firma'); + expect(usageCellText(invoiceAmountHeader.text)).toBe('Betrag'); - // Both are single unbroken words wider than their column even at REAL (not worst-case) - // metrics, per the architect's own measurement — so both must genuinely wrap across - // multiple rendered lines, not merely carry the flag without needing it. + // Both new labels are short enough to fit their columns without wrapping — each renders + // as exactly 1 line. This is the regression guard: a future DE translation that reintroduces + // a wide single-token word would push positions.length above 1. expect(vendorHeader.positions).toBeDefined(); - expect(vendorHeader.positions!.length).toBeGreaterThan(1); + expect(vendorHeader.positions!.length).toEqual(1); expect(invoiceAmountHeader.positions).toBeDefined(); - expect(invoiceAmountHeader.positions!.length).toBeGreaterThan(1); + expect(invoiceAmountHeader.positions!.length).toEqual(1); }); it('[HIGH1] "Zugeordneter Betrag" (allocatedAmount header, 75pt column) is NOT force-broken mid-character — it renders as exactly 2 lines (one word per line, wrapped at the natural space), proving the conservative per-token flag on "Zugeordneter" never actually needed to invoke a mid-character split', async () => { @@ -1995,12 +1997,12 @@ describe('report PDF pipeline — real, unmocked end-to-end render', () => { expect(allocatedHeader.positions!.length).toBe(2); }); - it('[HIGH1] the claim (6-column) shape header row also renders "Auftragnehmer"/"Rechnungsbetrag" without throwing and with full text recoverable — the same protection applies regardless of table shape', async () => { + it('[HIGH1] the claim (6-column) shape header row also renders "Firma"/"Betrag" without throwing and with full text recoverable — the same protection applies regardless of table shape (#1937)', async () => { const { headerRow } = await renderGermanHeaderRow('claim'); const vendorHeader = headerRow[0] as { text: unknown }; const invoiceAmountHeader = headerRow[3] as { text: unknown }; // no status column in claim shape - expect(usageCellText(vendorHeader.text)).toBe('Auftragnehmer'); - expect(usageCellText(invoiceAmountHeader.text)).toBe('Rechnungsbetrag'); + expect(usageCellText(vendorHeader.text)).toBe('Firma'); + expect(usageCellText(invoiceAmountHeader.text)).toBe('Betrag'); }); }); @@ -2815,7 +2817,8 @@ describe('production i18n singleton — getFixedT resolves a language independen expect(i18n.language).toBe('en'); const fixedDe = i18n.getFixedT('de', 'budget'); - expect(fixedDe('sourceReports.table.vendor')).toBe('Auftragnehmer'); + // #1937: DE vendor label changed from "Auftragnehmer" to "Firma" (fits the 45pt column). + expect(fixedDe('sourceReports.table.vendor')).toBe('Firma'); expect(fixedDe('sourceReports.download')).toBe('PDF herunterladen'); // Calling getFixedT for a different locale must not mutate the singleton's own active @@ -2926,3 +2929,55 @@ describe('production i18n singleton — getFixedT resolves a language independen expect(allocatedCell.text[1]!.text).toBe(' (Abschlagszahlung)'); }); }); + +// ─── #1937: DE header-label column-fit pin (AC7) ───────────────────────────────────────────────── +// +// Pins the DE label lengths for the two narrow fixed-width columns whose German translations +// previously overflowed: Vendor (45pt column) and Invoice Amount (48pt column). +// +// Bound derivation: the Roboto font's measured average character advance at 10pt bold +// (the table header font) is 5.19pt/char — derived from "Auftragnehmer" (13 chars) measuring +// 67.50pt in a real render, giving 67.50 / 13 = 5.19pt/char. That yields practical column +// capacities of floor(45 / 5.19) = 8 chars for Vendor and floor(48 / 5.19) = 9 chars for +// Invoice Amount. A single-token DE label shorter than these bounds will fit without wrapping +// even at the AVERAGE glyph width, not just the conservatively wide worst-case metric. +// +// The HIGH1 tests above exercise the same labels via a full real pdfmake render and assert +// that each header cell resolves to exactly 1 rendered line — a stronger, renderer-level proof +// of the same property. The length assertions here are a cheap structural guard: if a future +// translation lands a wider single-token word, the length check fires immediately without +// needing the full pdfmake render cycle. +describe('#1937 AC7: DE header labels fit their narrow fixed-width columns (column-fit pin)', () => { + // Measured average glyph advance at 10pt bold Roboto (derived from "Auftragnehmer" real render: + // 67.50pt / 13 chars = 5.19pt/char). Used to compute realistic per-column character capacities. + const AVG_CHAR_WIDTH_PT = 5.19; + const VENDOR_WIDTH_PT = 45; + const INVOICE_AMOUNT_WIDTH_PT = 48; + // A single-token label this length or shorter fits without pdfmake needing to word-wrap it. + const VENDOR_COLUMN_CHAR_CAPACITY = Math.floor(VENDOR_WIDTH_PT / AVG_CHAR_WIDTH_PT); // 8 + const INVOICE_AMOUNT_COLUMN_CHAR_CAPACITY = Math.floor( + INVOICE_AMOUNT_WIDTH_PT / AVG_CHAR_WIDTH_PT, + ); // 9 + + it('DE vendor label ("Firma") is at most VENDOR_COLUMN_CHAR_CAPACITY chars — fits the 45pt column without wrapping', () => { + // Uses the isolated i18next instance loaded with the real de/budget.json bundle (see beforeAll + // at the top of this file). The value must match the production JSON exactly. + const deVendorLabel = tDe('sourceReports.table.vendor'); + expect(deVendorLabel).toBe('Firma'); // exact current value pin + expect(deVendorLabel.length).toBeLessThanOrEqual(VENDOR_COLUMN_CHAR_CAPACITY); + }); + + it('DE invoiceAmount label ("Betrag") is at most INVOICE_AMOUNT_COLUMN_CHAR_CAPACITY chars — fits the 48pt column without wrapping', () => { + const deInvoiceAmountLabel = tDe('sourceReports.table.invoiceAmount'); + expect(deInvoiceAmountLabel).toBe('Betrag'); // exact current value pin + expect(deInvoiceAmountLabel.length).toBeLessThanOrEqual(INVOICE_AMOUNT_COLUMN_CHAR_CAPACITY); + }); + + it('EN vendor and invoiceAmount labels are unchanged from their baseline values', () => { + // EN labels are stable reference points: "Vendor" (6 chars, fits the 45pt column); + // "Invoice Amount" (14 chars) has an internal space so pdfmake wraps at the word boundary — + // it never needs break-all and is not subject to the single-token width constraint. + expect(tEn('sourceReports.table.vendor')).toBe('Vendor'); + expect(tEn('sourceReports.table.invoiceAmount')).toBe('Invoice Amount'); + }); +}); From 90dc83005ae23281b3e0def7da719e81f35324ba Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 10:35:49 +0200 Subject: [PATCH 06/42] fix(budget): make budget-source drill-down deposit-aware (#1897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced deposit-blind `getWorkItemLineInvoiceData` / `getHouseholdItemLineInvoiceData` helpers with deposit-aware `getInvoiceAggregates` from `budgetServiceFactory` - `hasClaimedInvoice` now uses a status-existence check (`rows.some(r => r.invoice_status === 'claimed' || r.deposit_status === 'claimed')`) rather than an amount threshold — correctly handles claimed invoices fully covered by non-claimed deposits - Fixed rider ternary: `new Set([status])` (was coercing non-'claimed' to 'paid') - Updated `wiki/API-Contract.md` field notes for `hasClaimedInvoice` and `actualCostPaid` Fixes #1897 Co-Authored-By: Claude backend-developer Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester --- .../agent-memory/product-architect/MEMORY.md | 2 +- .../product-architect/client-pdf-pipeline.md | 8 +- .../product-architect/recurring-patterns.md | 39 ++- .../product-architect/story-reviews.md | 52 +++- .../qa-integration-tester/MEMORY.md | 2 + .../bug-1897-deposit-blind-drilldown.md | 45 +++ .../src/services/budgetSourceService.test.ts | 266 ++++++++++++++++++ server/src/services/budgetSourceService.ts | 86 +----- .../services/shared/budgetServiceFactory.ts | 10 +- wiki | 2 +- 10 files changed, 419 insertions(+), 93 deletions(-) create mode 100644 .claude/agent-memory/qa-integration-tester/bug-1897-deposit-blind-drilldown.md diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index a4d656cae..cf3a12707 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,7 +2,7 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959) +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated - [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 diff --git a/.claude/agent-memory/product-architect/client-pdf-pipeline.md b/.claude/agent-memory/product-architect/client-pdf-pipeline.md index 1268cbbcc..e01588515 100644 --- a/.claude/agent-memory/product-architect/client-pdf-pipeline.md +++ b/.claude/agent-memory/product-architect/client-pdf-pipeline.md @@ -318,21 +318,21 @@ pdfmake's `elasticWidth` never grows a fixed column to fit its own header. So ** `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 + 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 + 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 + 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` (`` 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 + 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 diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 6384e2f90..9eb5ba878 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -32,7 +32,7 @@ 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 +### 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 @@ -44,11 +44,11 @@ longer emits. **Whenever `merge.ts`'s docDefinition changes, grep this helper.** ### 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) — +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 +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 @@ -530,3 +530,36 @@ Why this is worth blocking on (I did, r2): the POM class docstring is the contra lying test title is a complete instruction set for deleting the coverage the PR exists to add — and with `E2E Gates` main-only, that deletion lands on `beta` silently. It is the same mechanism that produced #1965: #1959 removed a producer and left comments asserting the removal was permanent. + +## Amount-threshold booleans silently narrow status-existence booleans (PR #1984, #1897) + +When a deposit-blind SQL helper is collapsed into the shared deposit-aware path, the _money_ fields +(`actualCost`, `actualCostPaid`) port cleanly but any **boolean** flag does not. #1984 re-derived +`hasClaimedInvoice` from `actualCostClaimed > 0` where the old SQL used +`COUNT(CASE WHEN i.status = 'claimed' ...) > 0`. Those are different predicates: + +- **Gains** the intended case (pending invoice + claimed deposit → `true`). +- **Loses** a claimed invoice whose deposits fully cover it with non-claimed status: `residualFraction` + is 0 in `splitByDeposits`, so the claimed residual contributes nothing and the flag flips to `false`. +- **Loses** a refund-neutralised claim (refunds carry a negative fraction, netting the bucket to 0). + +Fix shape: derive booleans from statuses on the raw rows, never from a post-split amount — +`rows.some((r) => r.invoice_status === 'claimed' || r.deposit_status === 'claimed')`. Strict superset of +the old predicate, threshold-free, so rounding and refunds cannot flip it. + +**Why this is worth blocking on**: the flag's only consumer was `MassMoveModal`'s `claimedCount`, which +gates the "I understand" confirmation before mass-moving bank-claimed lines. A display-parity bug fix +quietly disabled a safety confirmation. Generalise: before accepting a boolean's re-derivation, find its +consumers — if any is a guard rail rather than a label, demand predicate equivalence, not "the tests pass". + +**Companion test smell**: every pre-existing claimed-invoice test used an invoice with **no deposits**, so +`residualFraction === 1` and the two predicates coincide. A whole suite can agree with a wrong predicate +because no case exercises the branch where they differ. Ask "which fixture makes the old and new +definitions disagree?" and require exactly that fixture. + +## Prettier is not CI-gated — the local gate is the only gate + +`static-analysis` in `.github/workflows/ci.yml` runs `npm audit signatures`, `npm run typecheck`, and +Stylelint. No `format:check`, no ESLint. So `npx prettier --check ` on review is worth the +ten seconds: #1984 shipped two violations (a 101-char inline return type, and a rider edit left +artificially wrapped after the expression shortened) that nothing downstream would have caught. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index 3ec67f91c..348ada3de 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -533,7 +533,7 @@ interface `t` returns the bare key, so a regression to `t()` fails rather than p 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); +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`. @@ -541,3 +541,53 @@ footer page label still uses the interface `t`. (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. + +## PR #1984 — deposit-aware budget-source drill-down (#1897) — CHANGES_REQUIRED (2026-08-04) + +The structural fix is right: two forked deposit-blind SQL helpers (`getWorkItemLineInvoiceData`, +`getHouseholdItemLineInvoiceData`) deleted in favour of `getInvoiceAggregates(db, line.id, +'work_item_budget_id' | 'household_item_budget_id')`. FK columns correct, no circular import +(`budgetServiceFactory` does not import `budgetSourceService`), additive for `ResolvedBudgetRelations` +(it destructures only three fields and the `undefined`-column fallback literal keeps those three, so the +union resolves). `invoiceCount`'s row-count → distinct-invoice change is a no-op because +`invoice_budget_lines` has _partial unique indexes_ on `work_item_budget_id` / `household_item_budget_id` +(`schema.ts:457-462`) — at most one ibl row per budget line. Worth remembering: that constraint makes the +"one line, many invoices" mental model wrong, and makes `wiki/API-Contract.md`'s `"invoiceCount": 2` +example impossible. + +Two blocking findings: + +- **HIGH-1** `hasClaimedInvoice: actualCostClaimed > 0` — see recurring-patterns.md + ("Amount-threshold booleans silently narrow status-existence booleans"). +- **HIGH-2** the change broadens a field documented at `wiki/API-Contract.md:4694` ("whether any linked + invoice has status `'claimed'`") without a wiki update; `actualCostPaid`'s field note on the same + endpoint is also stale (still describes whole-invoice-by-status, not the proportional split). + +Plus MEDIUM prettier violations and a MEDIUM test gap (AC8: claimed invoice fully covered by `paid` +deposits). Non-blocking: `hasClaimedInvoice` is now a misnomer — flagged as a polish follow-up, not a +rename in this PR. + +The rider (`new Set([status])`) is genuinely untestable: `computeDiscretionaryInvoiceAmount` is +module-private with two call sites passing only `'claimed'`/`'paid'`, so no test can distinguish old from +new. AC7 is honestly labelled a regression guard — accepted as-is rather than demanding a contrived test. + +**Process note**: the PR's GitHub author is `steilerDev` (the orchestrator's token), so +`gh pr review --request-changes` is rejected as a self-review. Used `gh pr comment` and stated the verdict +in the body — same workaround already noted in MEMORY.md for `--approve`. + +### Round 2 (`1f9de9b8`) — APPROVED + +Both HIGHs fixed as specified. `hasClaimedInvoice` is now +`rows.some((r) => r.invoice_status === 'claimed' || r.deposit_status === 'claimed')` — derived from the raw +join tuples _before_ `splitByDeposits`, so residual/refund arithmetic cannot reach it, and empty `rows` +still yields `false` (matches the old `COUNT(...) > 0`). Wiki `API-Contract.md:4694,4696` updated (wiki +commit `e744969`, submodule ref bumped **on the branch** — the ordering rule held). AC8 verified to be a +real mutation-killer, not a restatement: invoice 1000 `claimed` + deposit 1000 `paid` gives +`residualFraction = 0` and no claimed deposit, so the old predicate returned `false`. + +Three follow-ups left open, all informational: the `hasClaimedInvoice` rename (a claimed **refund** flips +it too — same issue), and `wiki/API-Contract.md`'s unreachable `"invoiceCount": 2` example. Also noted for +the record: `actualCostPaid`'s "Quotations are always excluded" wiki note is now only approximate — a +`quotation` invoice with a `paid` deposit contributes that portion under the proportional split. That is a +property of `computeDepositAwareAggregates`, shared by **every** consumer of the deposit-aware path, so it +is a repo-wide question for `depositAggregateUtils.ts`, never a per-endpoint patch. diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index 633f8b800..b270d85ab 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -15,6 +15,8 @@ ## Recent bug/story notes (2026-08) +- [Bug #1897 — deposit-blind drill-down fix](bug-1897-deposit-blind-drilldown.md) (2026-08-04) — `getBudgetSourceBudgetLines` was deposit-blind; fix routes through `getInvoiceAggregates`; 7 tests in new `describe('deposit-aware drill-down')` block appended to `budgetSourceService.test.ts`; local WI/HI/deposit helpers; AC1 is the reproduction case. + - [PR #1959 — inline meta content loss + `it.failing` tripwires](pr-1959-inline-meta-content-loss.md) (2026-08-03, RESOLVED) — prod defect found+fixed (unchunked meta in a `dontBreakRows` cell silently drops pages); **a tripwire is worthless if a shared helper bakes in the buggy assumption** — mine nearly stayed green through the fix; channel-equivalence is the threshold-free assertion; tree-level assertions cannot see this bug class; **write NBSP as `\u00A0` in test expectations, never a literal** (I smuggled one into the guard against it); keep literal+invariant at different levels; non-positive chunk budgets HANG not throw; `grep` silently returns nothing on these test files (use `awk`). - [Bug #1955 — echo-race harness + mutation probes](bug-1955-echo-race-harness.md) (2026-08-03) — echo must fire on the write's *resolve* (not the call) or the queue fix masks the guard and the test passes pre-fix; `rerender()` stands in for the optimistic `setPreferences`; 4 perl mutation probes prove each test guards a distinct part of the fix; never run repo-wide `npm run format` (38 unrelated files drift). diff --git a/.claude/agent-memory/qa-integration-tester/bug-1897-deposit-blind-drilldown.md b/.claude/agent-memory/qa-integration-tester/bug-1897-deposit-blind-drilldown.md new file mode 100644 index 000000000..cfd57d90d --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/bug-1897-deposit-blind-drilldown.md @@ -0,0 +1,45 @@ +--- +name: bug-1897-deposit-blind-drilldown +description: Issue #1897 — getBudgetSourceBudgetLines was deposit-blind; fix routes through getInvoiceAggregates; 7 integration tests added at end of budgetSourceService.test.ts +metadata: + type: project +--- + +## Fix + +`buildWorkItemBudgetLine` and `buildHouseholdItemBudgetLine` in `budgetSourceService.ts` previously used a deposit-blind local SQL helper to compute `actualCostPaid` and `hasClaimedInvoice`. The fix routes both through `getInvoiceAggregates(db, line.id, 'work_item_budget_id' | 'household_item_budget_id')` from `budgetServiceFactory.ts`, which uses `computeDepositAwareAggregates`. + +Rider fix: `computeDiscretionaryInvoiceAmount` changed `new Set([status === 'claimed' ? 'claimed' : 'paid'])` → `new Set([status])`. Behavior is identical for 'paid'/'claimed' (the only caller-supplied values). + +**Why:** Pre-fix, a pending invoice with a €400 paid deposit returned `actualCostPaid = 0` in the drill-down view. + +## Tests added + +File: `server/src/services/budgetSourceService.test.ts` — new `describe('deposit-aware drill-down — getBudgetSourceBudgetLines (#1897)')` appended at end of outer describe (before final `});`). + +7 tests (AC1–AC7): +- AC1: reproduction — pending invoice + paid deposit → `actualCostPaid = 400`, `actualCost = 1000` +- AC2: pending invoice, no deposits → `actualCostPaid = 0` (no regression) +- AC3: paid invoice, no deposits → `actualCostPaid = 800` (no regression) +- AC4: claimed invoice → `hasClaimedInvoice = true`, `actualCostPaid = 600` +- AC5: pending invoice + claimed deposit → `hasClaimedInvoice = true`, `actualCostPaid = 300` +- AC6: household item variant — pending invoice + paid deposit → `householdItemLines[0].actualCostPaid = 500` +- AC7: rider regression — `computeDiscretionaryInvoiceAmount` via `getBudgetSourceById` on `discretionary-system` source + +Local helpers defined in the new describe scope: +- `insertInvoiceForWILine(budgetLineId, amount, status)` — vendor + invoice + ibl (workItemBudgetId FK) +- `insertDeposit(invoiceId, amount, status)` — plain deposit, budgetSourceId=null +- `insertInvoiceForHILine(budgetLineId, amount, status)` — vendor + invoice + ibl (householdItemBudgetId FK) + +## Coverage + +Changed functions (`buildWorkItemBudgetLine`, `buildHouseholdItemBudgetLine`, `computeDiscretionaryInvoiceAmount`) fully covered. Overall `budgetSourceService.ts` file: 82% (moveBudgetSourceBudgetLines and compareBudgetSourceLines bring average down — unrelated to fix). + +## Worktree symlink needed + +Test run required creating symlinks first: +```bash +ln -sf /main/node_modules /worktree/node_modules +ln -sf /main/server/node_modules /worktree/server/node_modules +``` +Then: `NODE_OPTIONS=--experimental-vm-modules npx jest server/src/services/budgetSourceService.test.ts --maxWorkers=1` diff --git a/server/src/services/budgetSourceService.test.ts b/server/src/services/budgetSourceService.test.ts index d697bf7e6..76dd41565 100644 --- a/server/src/services/budgetSourceService.test.ts +++ b/server/src/services/budgetSourceService.test.ts @@ -3323,4 +3323,270 @@ describe('Budget Source Service', () => { }); }); }); + + // ─── #1897 deposit-aware drill-down via getBudgetSourceBudgetLines ────────── + + describe('deposit-aware drill-down — getBudgetSourceBudgetLines (#1897)', () => { + /** + * Helper: insert a vendor + invoice + invoice_budget_line for a work item budget line. + * Uses workItemBudgetId FK. Returns the invoiceId. + */ + function insertInvoiceForWILine( + budgetLineId: string, + amount: number, + status: 'pending' | 'paid' | 'claimed', + ): string { + const ts = new Date(Date.now() + workItemCounter).toISOString(); + const vendorId = `vendor-1897-wi-${++workItemCounter}`; + db.insert(schema.vendors) + .values({ id: vendorId, name: `1897 WI Vendor ${vendorId}`, createdAt: ts, updatedAt: ts }) + .run(); + const invoiceId = `inv-1897-wi-${workItemCounter}`; + db.insert(schema.invoices) + .values({ + id: invoiceId, + vendorId, + amount, + date: '2026-01-01', + status, + createdAt: ts, + updatedAt: ts, + }) + .run(); + db.insert(schema.invoiceBudgetLines) + .values({ + id: randomUUID(), + invoiceId, + workItemBudgetId: budgetLineId, + itemizedAmount: amount, + createdAt: ts, + updatedAt: ts, + }) + .run(); + return invoiceId; + } + + /** + * Helper: insert a deposit for a given invoice. + * budgetSourceId defaults to null (untagged), matching the deposit-blind repro scenario. + * Returns the deposit ID. + */ + function insertDeposit( + invoiceId: string, + amount: number, + status: 'pending' | 'paid' | 'claimed', + ): string { + const id = `dep-1897-${++workItemCounter}`; + const ts = new Date(Date.now() + workItemCounter).toISOString(); + db.insert(schema.invoiceDeposits) + .values({ + id, + invoiceId, + amount, + dueDate: '2026-03-01', + status, + entryType: 'deposit', + budgetSourceId: null, + createdAt: ts, + updatedAt: ts, + }) + .run(); + return id; + } + + /** + * Helper: insert a vendor + invoice + invoice_budget_line for a household item budget line. + * Uses householdItemBudgetId FK instead of workItemBudgetId. Returns the invoiceId. + */ + function insertInvoiceForHILine( + budgetLineId: string, + amount: number, + status: 'pending' | 'paid' | 'claimed', + ): string { + const ts = new Date(Date.now() + householdItemCounter).toISOString(); + const vendorId = `vendor-1897-hi-${++householdItemCounter}`; + db.insert(schema.vendors) + .values({ id: vendorId, name: `1897 HI Vendor ${vendorId}`, createdAt: ts, updatedAt: ts }) + .run(); + const invoiceId = `inv-1897-hi-${householdItemCounter}`; + db.insert(schema.invoices) + .values({ + id: invoiceId, + vendorId, + amount, + date: '2026-01-01', + status, + createdAt: ts, + updatedAt: ts, + }) + .run(); + db.insert(schema.invoiceBudgetLines) + .values({ + id: randomUUID(), + invoiceId, + householdItemBudgetId: budgetLineId, + itemizedAmount: amount, + createdAt: ts, + updatedAt: ts, + }) + .run(); + return invoiceId; + } + + it('AC1 — reproduction: pending invoice with paid deposit → actualCostPaid = deposit amount (was 0 before fix)', () => { + // The broken case: pre-fix, buildWorkItemBudgetLine used deposit-blind SQL that ignored + // invoice_deposits entirely, so a pending invoice with a paid €400 deposit returned + // actualCostPaid = 0. The fix routes through getInvoiceAggregates (budgetServiceFactory) + // which uses computeDepositAwareAggregates. + const src = insertRawSource({ name: 'AC1 Pending+Deposit Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 1000); + const invoiceId = insertInvoiceForWILine(budgetId, 1000, 'pending'); + insertDeposit(invoiceId, 400, 'paid'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines).toHaveLength(1); + expect(result.workItemLines[0]!.actualCost).toBe(1000); + expect(result.workItemLines[0]!.actualCostPaid).toBeCloseTo(400); + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(false); + }); + + it('AC2 — pending invoice, no deposits: actualCostPaid = 0, actualCost = invoice amount (no regression)', () => { + const src = insertRawSource({ name: 'AC2 Pending No-Deposit Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 500); + insertInvoiceForWILine(budgetId, 500, 'pending'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines[0]!.actualCost).toBe(500); + expect(result.workItemLines[0]!.actualCostPaid).toBe(0); + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(false); + }); + + it('AC3 — paid invoice, no deposits: actualCostPaid = invoice amount (no regression)', () => { + const src = insertRawSource({ name: 'AC3 Paid No-Deposit Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 800); + insertInvoiceForWILine(budgetId, 800, 'paid'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines[0]!.actualCost).toBe(800); + expect(result.workItemLines[0]!.actualCostPaid).toBeCloseTo(800); + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(false); + }); + + it('AC4 — claimed invoice, no deposits: hasClaimedInvoice = true, actualCostPaid = invoice amount', () => { + // actualCostPaid includes both 'paid' and 'claimed' contributions per depositAggregateUtils. + const src = insertRawSource({ name: 'AC4 Claimed Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 600); + insertInvoiceForWILine(budgetId, 600, 'claimed'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(true); + expect(result.workItemLines[0]!.actualCostPaid).toBeCloseTo(600); + expect(result.workItemLines[0]!.actualCost).toBe(600); + }); + + it('AC5 — pending invoice with claimed deposit: hasClaimedInvoice = true, actualCostPaid = deposit amount', () => { + // Deposit is claimed → the deposit fraction contributes to actualCostClaimed, + // flipping hasClaimedInvoice = true even though the invoice itself is pending. + const src = insertRawSource({ name: 'AC5 Claimed-Deposit Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 1000); + const invoiceId = insertInvoiceForWILine(budgetId, 1000, 'pending'); + insertDeposit(invoiceId, 300, 'claimed'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(true); + expect(result.workItemLines[0]!.actualCostPaid).toBeCloseTo(300); + expect(result.workItemLines[0]!.actualCost).toBe(1000); + }); + + it('AC6 — household item variant: pending invoice with paid deposit → actualCostPaid = deposit amount', () => { + // Verifies the deposit-aware path through buildHouseholdItemBudgetLine, which uses + // getInvoiceAggregates with 'household_item_budget_id' as the FK column. + const src = insertRawSource({ name: 'AC6 HI Source', totalAmount: 50000 }); + const { budgetId } = insertRawHouseholdItemWithSource(src.id, 1200); + const invoiceId = insertInvoiceForHILine(budgetId, 1200, 'pending'); + insertDeposit(invoiceId, 500, 'paid'); + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.householdItemLines).toHaveLength(1); + expect(result.householdItemLines[0]!.actualCost).toBe(1200); + expect(result.householdItemLines[0]!.actualCostPaid).toBeCloseTo(500); + expect(result.householdItemLines[0]!.hasClaimedInvoice).toBe(false); + }); + + it('AC8 — claimed invoice fully covered by paid deposits still sets hasClaimedInvoice = true', () => { + // Edge case for the hasClaimedInvoice fix (issue #1897): + // When a claimed invoice is 100% covered by paid (not claimed) deposits, + // residualFraction = 0, so actualCostClaimed = 0. + // Old logic (actualCostClaimed > 0) would incorrectly return hasClaimedInvoice = false. + // New logic checks rows.some(r => r.invoice_status === 'claimed' || r.deposit_status === 'claimed'), + // which sees invoice_status = 'claimed' and correctly returns true. + const src = insertRawSource({ name: 'AC8 Claimed+PaidDeposit Source', totalAmount: 50000 }); + const { budgetId } = insertRawWorkItemWithSource(src.id, 1000); + const invoiceId = insertInvoiceForWILine(budgetId, 1000, 'claimed'); + insertDeposit(invoiceId, 1000, 'paid'); // fully covers the invoice → residualFraction = 0 + + const result = budgetSourceService.getBudgetSourceBudgetLines(db, src.id); + + expect(result.workItemLines).toHaveLength(1); + // residualFraction = 0, so the residual portion contributes 0 to actualCostClaimed, + // but the paid deposit contributes its full amount to actualCostPaid. + expect(result.workItemLines[0]!.actualCost).toBe(1000); + expect(result.workItemLines[0]!.actualCostPaid).toBeCloseTo(1000); + // The invoice IS claimed — hasClaimedInvoice must be true regardless of actualCostClaimed. + expect(result.workItemLines[0]!.hasClaimedInvoice).toBe(true); + }); + + it('AC7 — rider regression: computeDiscretionaryInvoiceAmount passes status through correctly for status="paid"', () => { + // The rider fix changed `new Set([status === 'claimed' ? 'claimed' : 'paid'])` to + // `new Set([status])`. For the two caller-supplied values ('paid', 'claimed') the behavior + // is identical, so this is a regression guard only. + // Exercises Rail B of computeDiscretionaryInvoiceAmount via getBudgetSourceById on the + // seeded discretionary-system source with a pending invoice + paid tagged deposit. + // Additional coverage exists in 'Rail B — tagged deposits (Story #1891)' > + // 'discretionary variant: tagged deposit on the discretionary source'. + const ts = new Date(Date.now() + workItemCounter).toISOString(); + const vendorId = `vendor-ac7-${++workItemCounter}`; + db.insert(schema.vendors) + .values({ id: vendorId, name: `AC7 Rider Vendor`, createdAt: ts, updatedAt: ts }) + .run(); + const invoiceId = `inv-ac7-${workItemCounter}`; + db.insert(schema.invoices) + .values({ + id: invoiceId, + vendorId, + amount: 1000, + date: '2026-01-01', + status: 'pending', + createdAt: ts, + updatedAt: ts, + }) + .run(); + const depositAmount = 250; + db.insert(schema.invoiceDeposits) + .values({ + id: `dep-ac7-${workItemCounter}`, + invoiceId, + amount: depositAmount, + dueDate: '2026-03-01', + status: 'paid', + entryType: 'deposit', + budgetSourceId: 'discretionary-system', + createdAt: ts, + updatedAt: ts, + }) + .run(); + + // getBudgetSourceById calls getSourceAmounts which, for the discretionary source, + // calls computeDiscretionaryInvoiceAmount(db, 'paid') → Rail B picks up the tagged deposit. + const disc = budgetSourceService.getBudgetSourceById(db, 'discretionary-system'); + expect(disc.unclaimedAmount).toBeCloseTo(depositAmount); + expect(disc.claimedAmount).toBe(0); + }); + }); }); diff --git a/server/src/services/budgetSourceService.ts b/server/src/services/budgetSourceService.ts index e07e3e28a..9caf50ac1 100644 --- a/server/src/services/budgetSourceService.ts +++ b/server/src/services/budgetSourceService.ts @@ -21,6 +21,7 @@ import { sumTaggedDepositContributions, type DepositAwareRow, } from './shared/depositAggregateUtils.js'; +import { getInvoiceAggregates } from './shared/budgetServiceFactory.js'; import type { BudgetSource, BudgetSourceType, @@ -391,10 +392,7 @@ function computeDiscretionaryInvoiceAmount(db: DbType, status: string): number { WHERE d.budget_source_id = ${discretionarySourceId}`, ); - railBAmount = sumTaggedDepositContributions( - railBRows, - new Set([status === 'claimed' ? 'claimed' : 'paid']), - ); + railBAmount = sumTaggedDepositContributions(railBRows, new Set([status])); } return remainderTotal + noSourceTotal + railBAmount; @@ -811,82 +809,6 @@ export function deleteBudgetSource(db: DbType, id: string): void { db.delete(budgetSources).where(eq(budgetSources.id, id)).run(); } -/** - * Get invoice aggregates for a work item budget line. - * Returns actualCost (sum of all itemized amounts), actualCostPaid (sum of paid/claimed amounts), - * and invoiceCount (number of linked invoices). - */ -function getWorkItemLineInvoiceData( - db: DbType, - lineId: string, -): { - actualCost: number; - actualCostPaid: number; - invoiceCount: number; - hasClaimedInvoice: boolean; -} { - const result = db.get<{ - actualCost: number; - actualCostPaid: number; - invoiceCount: number; - hasClaimedInvoice: number; - }>( - sql`SELECT - COALESCE(SUM(ibl.itemized_amount), 0) AS actualCost, - COALESCE(SUM(CASE WHEN i.status IN ('paid', 'claimed') THEN ibl.itemized_amount ELSE 0 END), 0) AS actualCostPaid, - COUNT(*) AS invoiceCount, - CASE WHEN COUNT(CASE WHEN i.status = 'claimed' THEN 1 END) > 0 THEN 1 ELSE 0 END AS hasClaimedInvoice - FROM invoice_budget_lines ibl - INNER JOIN invoices i ON i.id = ibl.invoice_id - WHERE ibl.work_item_budget_id = ${lineId}`, - ); - - return { - actualCost: result?.actualCost ?? 0, - actualCostPaid: result?.actualCostPaid ?? 0, - invoiceCount: result?.invoiceCount ?? 0, - hasClaimedInvoice: (result?.hasClaimedInvoice ?? 0) === 1, - }; -} - -/** - * Get invoice aggregates for a household item budget line. - * Returns actualCost (sum of all itemized amounts), actualCostPaid (sum of paid/claimed amounts), - * and invoiceCount (number of linked invoices). - */ -function getHouseholdItemLineInvoiceData( - db: DbType, - lineId: string, -): { - actualCost: number; - actualCostPaid: number; - invoiceCount: number; - hasClaimedInvoice: boolean; -} { - const result = db.get<{ - actualCost: number; - actualCostPaid: number; - invoiceCount: number; - hasClaimedInvoice: number; - }>( - sql`SELECT - COALESCE(SUM(ibl.itemized_amount), 0) AS actualCost, - COALESCE(SUM(CASE WHEN i.status IN ('paid', 'claimed') THEN ibl.itemized_amount ELSE 0 END), 0) AS actualCostPaid, - COUNT(*) AS invoiceCount, - CASE WHEN COUNT(CASE WHEN i.status = 'claimed' THEN 1 END) > 0 THEN 1 ELSE 0 END AS hasClaimedInvoice - FROM invoice_budget_lines ibl - INNER JOIN invoices i ON i.id = ibl.invoice_id - WHERE ibl.household_item_budget_id = ${lineId}`, - ); - - return { - actualCost: result?.actualCost ?? 0, - actualCostPaid: result?.actualCostPaid ?? 0, - invoiceCount: result?.invoiceCount ?? 0, - hasClaimedInvoice: (result?.hasClaimedInvoice ?? 0) === 1, - }; -} - /** * Get the first linked invoice for a budget line (or null if none). * Used for work item budget lines. @@ -989,7 +911,7 @@ function buildWorkItemBudgetLine( ? db.select().from(users).where(eq(users.id, line.createdBy)).get() : null; - const invoiceData = getWorkItemLineInvoiceData(db, line.id); + const invoiceData = getInvoiceAggregates(db, line.id, 'work_item_budget_id'); const invoiceLink = getWorkItemLineInvoiceLink(db, line.id); return { @@ -1050,7 +972,7 @@ function buildHouseholdItemBudgetLine( ? db.select().from(users).where(eq(users.id, line.createdBy)).get() : null; - const invoiceData = getHouseholdItemLineInvoiceData(db, line.id); + const invoiceData = getInvoiceAggregates(db, line.id, 'household_item_budget_id'); const invoiceLink = getHouseholdItemLineInvoiceLink(db, line.id); return { diff --git a/server/src/services/shared/budgetServiceFactory.ts b/server/src/services/shared/budgetServiceFactory.ts index 896f7439e..f71306a8d 100644 --- a/server/src/services/shared/budgetServiceFactory.ts +++ b/server/src/services/shared/budgetServiceFactory.ts @@ -366,7 +366,12 @@ export function getInvoiceAggregates( db: DbType, budgetId: string, invoiceBudgetIdColumn: string, -): { actualCost: number; actualCostPaid: number; invoiceCount: number } { +): { + actualCost: number; + actualCostPaid: number; + invoiceCount: number; + hasClaimedInvoice: boolean; +} { // Fetch (ibl, invoice, deposit?) tuples with deposit-aware split const rows = db.all( sql`SELECT @@ -391,6 +396,9 @@ export function getInvoiceAggregates( actualCost, actualCostPaid, invoiceCount, + hasClaimedInvoice: rows.some( + (r) => r.invoice_status === 'claimed' || r.deposit_status === 'claimed', + ), }; } diff --git a/wiki b/wiki index f9cd21c8d..e74496988 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit f9cd21c8db3f98b1315aeadaa0287ce5ae241cfc +Subproject commit e744969887f8e7105287641528ac4ae8ec295529 From ae902e502ab20f6a9b3da4934cbbcdc3ec953da1 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 11:17:12 +0200 Subject: [PATCH 07/42] fix(e2e): make email-search test self-contained and filtering-assertive (#1971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrote `Search filters by email` to use worker-scoped `testPrefix`, seeded match + non-match users, and bidirectional filtering assertions - Added universal-negative loops to `Search is case-insensitive` and `Search updates results dynamically` verifying every rendered row matches the query term (name and email cells, matching `UserManagementPage.tsx`'s filter) - Added `fullRows.length ≤ partialRows.length` narrowing assertion to the dynamic test - Used `Date.now()` suffix in seed email to avoid collisions with deactivated-but-not-deleted prior rows Fixes #1971 Co-Authored-By: Claude e2e-test-engineer Co-Authored-By: Claude product-architect --- .../product-architect/recurring-patterns.md | 51 +++++++++ .../product-architect/story-reviews.md | 24 ++++ e2e/tests/admin/search-users.spec.ts | 103 +++++++++++++++--- 3 files changed, 161 insertions(+), 17 deletions(-) diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 9eb5ba878..971bc2c0d 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -563,3 +563,54 @@ definitions disagree?" and require exactly that fixture. Stylelint. No `format:check`, no ESLint. So `npx prettier --check ` on review is worth the ten seconds: #1984 shipped two violations (a 101-char inline return type, and a rider edit left artificially wrapped after the expression shortened) that nothing downstream would have caught. + +## Positive membership on a *shared fixture* row is not a filtering assertion (#1971, PR #1985) + +The stock "fix" for `expect(rows.length).toBeGreaterThan(0)` is to add +`expect(await getUserRow(TEST_ADMIN.email)).not.toBeNull()`. That closes nothing: the shared admin/fixture +row is present in the **unfiltered** list too, so the assertion passes verbatim when the filter is a no-op. +A filtering assertion needs one of: + +- a **universal negative** — loop every rendered row and assert it contains the query, or +- a **seeded non-matching row** asserted absent (the shape the `filters by email` rewrite in #1985 got right). + +Watch for the comment that ships alongside it claiming the new positive check "makes the `> 0` guard +meaningful" — a documented-but-false guarantee is worse than the bare `> 0`, because the next maintainer +stops looking. Block on the comment/code conflict even if the assertion itself is a mild improvement. + +Detail that bites when writing the universal-negative loop: `UserManagementPage.tsx` filters on +`displayName || email`, so assert on `` `${cells[0]} ${cells[1]}` `` — a name-cell-only check produces false +failures for rows that matched by email. Join the cells with a **space**: a query with no space in it cannot +then be matched by bridging two adjacent cells, so no false positives. + +Ranking the two remedies (settled on PR #1985 round 2, APPROVED): the universal-negative loop is only +discriminating when the table happens to contain a non-matching row. `e2e/playwright.config.ts` sets +`fullyParallel: true` across 16 shards and `e2e/fixtures/seed.ts` seeds only the setup admin, so a test can +land in a shard whose user table is nearly empty and a broken filter still passes vacuously. Treat the loop +as sufficient-to-approve (it can no longer pass while wrong rows render) but the **seeded non-matching row** +as the airtight form; ask for it as a follow-up, not a block. + +Positional cell indices (`cells[0]`/`cells[1]`) are coupled to `useColumnPreferences(pageKey, columns)`, +which persists both visibility **and** order. No E2E test toggles columns on `/settings/users` today and the +POM's `getUserRow` already assumes `td` nth(1) === email, so it is currently consistent — but a future +column toggle silently repoints those loops at role/date text. Prefer POM accessors resolved from header +text when this comes up again. + +E2E-only PRs: `Detect Changes` skips Static Analysis, unit shards, and Trailer Check, and `Quality Gates` +runs smoke only — so the changed spec's real result lives in the 16 `E2E Tests (Shard n/16)` runs, which are +non-gating on beta. Always tell the orchestrator to confirm the relevant shard is green on **that PR** before +merging (see MEMORY.md's "Beta merges past red E2E"). + +## E2E user "cleanup" never frees the email — DELETE /api/users is a soft delete + +`server/src/routes/users.ts` DELETE sets `deactivatedAt`; `userService.listUsers` returns deactivated rows +and the user-management page applies no default status filter. So `deleteUserViaApi` leaves the row visible +for the rest of the run, and `POST /api/users` still 409s on that email (`findByEmail` does not exclude +deactivated users). Consequences for review: + +- A `finally`-block "delete" comment claiming the DB is left clean is wrong — say *deactivated*. +- Deterministic seed emails (`${testPrefix}@…`) are one-shot per DB. Currently masked because Playwright + gives a retried test a fresh `workerIndex` (so `testPrefix` differs), but `--repeat-each` or a switch to + `parallelIndex` would make `createLocalUserViaApi`'s `expect(response.ok())` fail and mask the real + failure. Require `${testPrefix}-${Date.now()}@e2e-test.local` (precedent: `i18n-categories.spec.ts`). +- `deleteUserViaApi` ignores the response status, so cleanup failures in this family are always silent. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index 348ada3de..86c196455 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -591,3 +591,27 @@ the record: `actualCostPaid`'s "Quotations are always excluded" wiki note is now `quotation` invoice with a `paid` deposit contributes that portion under the proportional split. That is a property of `computeDepositAwareAggregates`, shared by **every** consumer of the deposit-aware path, so it is a repo-wide question for `depositAggregateUtils.ts`, never a per-endpoint patch. + +## #1971 / PR #1985 — email-search test self-containment (E2E-only) — CHANGES_REQUIRED + +Verdict posted as a `gh pr comment` (author was the authenticated user, so `gh pr review` self-review is +refused). The `Search filters by email` rewrite itself was correct and needed no changes — worker-scoped +`testPrefix` search term, seeded match + non-match, `finally` cleanup, and the negative assertion has real +teeth (`DataTable` does not slice `items`, so a no-op search renders every user and the absence check fires). + +Blocked on AC4: three of four audited `rows.length > 0` sites kept only a positive membership check against +the **shared admin row**, which passes on a no-op filter — and the committed comment claimed the opposite. +See [[recurring-patterns]] for the generalised pattern plus the soft-delete/seed-email findings. + +### Round 2 (`c23169f1`) — APPROVED + +Universal-negative loops added to `Search is case-insensitive` and both steps of `Search updates results +dynamically`, plus `fullRows.length <= partialRows.length`. Checks that made the monotonicity assertion +safe to accept: filtering is a client-side `useMemo` over a `users` array fetched once on mount, so both +reads come from one snapshot and `'admin'` narrowing `'ad'` under `includes()` cannot flake. Verified +`createLocalUser` stores the email verbatim (no lowercasing), so the POM's exact-equality `getUserRow` +still matches the uppercase `E2E-` prefix in `${testPrefix}-${Date.now()}@…`. Also confirmed `DataTable` +keeps both `tbody tr` rows and the mobile card list in the DOM, so the loops behave the same on all three +viewports. Three non-blocking follow-ups (loop-vs-seeded-row discriminating power, non-worker-scoped +`no-match-` email, positional cell indices vs column preferences) — all recorded in +[[recurring-patterns]]. diff --git a/e2e/tests/admin/search-users.spec.ts b/e2e/tests/admin/search-users.spec.ts index 0d6bd6a54..7bf3c0ff0 100644 --- a/e2e/tests/admin/search-users.spec.ts +++ b/e2e/tests/admin/search-users.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from '../../fixtures/auth.js'; import { UserManagementPage } from '../../pages/UserManagementPage.js'; import { TEST_ADMIN } from '../../fixtures/testData.js'; +import { createLocalUserViaApi, deleteUserViaApi } from '../../fixtures/apiHelpers.js'; test.describe('Search Users', () => { test('Search filters by name', async ({ page }) => { @@ -18,6 +19,9 @@ test.describe('Search Users', () => { // Then: Only matching users should be shown const rows = await userManagementPage.getUserRows(); + // AC4 audit: the for-loop below verifies every visible row contains 'admin' — the + // > 0 guard is required for that loop to be meaningful. No separate negative + // assertion is needed because any non-admin row would fail the loop body. expect(rows.length).toBeGreaterThan(0); // Verify all visible rows contain "Admin" in name @@ -27,22 +31,60 @@ test.describe('Search Users', () => { } }); - test('Search filters by email', async ({ page }) => { - const userManagementPage = new UserManagementPage(page); - - // Given: User is on user management page - await userManagementPage.goto(); - - // When: User searches by email fragment - await userManagementPage.searchUsers('e2e-test'); - - // Then: Matching users should be shown - const rows = await userManagementPage.getUserRows(); - expect(rows.length).toBeGreaterThan(0); - - // Verify results contain the search term - const adminRow = await userManagementPage.getUserRow(TEST_ADMIN.email); - expect(adminRow).not.toBeNull(); + test('Search filters by email', async ({ page, testPrefix }) => { + // AC1: use testPrefix (unique per worker) as the search term so this test does not + // collide with accumulated suite users that share "e2e-test". + // AC2: assert filtering actually worked — matchUser IS present, noMatchUser IS absent. + // AC3: both seed users are deactivated (soft delete) in the finally block regardless of failure. + // Use Date.now() suffix so the email is unique even if a prior run left a deactivated row + // with the same address (POST /api/users 409s on deactivated emails too). + const matchEmail = `${testPrefix}-${Date.now()}@e2e-test.local`; + const noMatchEmail = `no-match-${Date.now()}@e2e-test.local`; + let matchUserId: string | null = null; + let noMatchUserId: string | null = null; + + try { + const matchUser = await createLocalUserViaApi(page, { + email: matchEmail, + displayName: 'Match User', + password: 'P@ssword1234!', + }); + matchUserId = matchUser.id; + + const noMatchUser = await createLocalUserViaApi(page, { + email: noMatchEmail, + displayName: 'No Match User', + password: 'P@ssword1234!', + }); + noMatchUserId = noMatchUser.id; + + const userManagementPage = new UserManagementPage(page); + await userManagementPage.goto(); + + // When: User searches by a fragment unique to this test run + await userManagementPage.searchUsers(testPrefix); + + // Then: at least one row is present (sanity guard before membership checks) + const rows = await userManagementPage.getUserRows(); + expect(rows.length).toBeGreaterThan(0); + + // matchUser MUST appear in results + const matchRow = await userManagementPage.getUserRow(matchEmail); + expect( + matchRow, + `Expected ${matchEmail} in search results for "${testPrefix}"`, + ).not.toBeNull(); + + // noMatchUser MUST NOT appear in results + const noMatchRow = await userManagementPage.getUserRow(noMatchEmail); + expect( + noMatchRow, + `Expected ${noMatchEmail} to be absent from search results for "${testPrefix}"`, + ).toBeNull(); + } finally { + if (matchUserId) await deleteUserViaApi(page, matchUserId); + if (noMatchUserId) await deleteUserViaApi(page, noMatchUserId); + } }); test('Empty search shows all users', async ({ page }) => { @@ -91,6 +133,14 @@ test.describe('Search Users', () => { const rows = await userManagementPage.getUserRows(); expect(rows.length).toBeGreaterThan(0); + // Universal negative: every rendered row must match — checks both name and email cells + // because UserManagementPage.tsx filters on `displayName || email` (a name-only check + // would false-fail rows that matched by email). + for (const row of rows) { + const cells = await row.locator('td').allTextContents(); + expect(`${cells[0]} ${cells[1]}`.toLowerCase()).toContain('admin'); + } + const adminRow = await userManagementPage.getUserRow(TEST_ADMIN.email); expect(adminRow).not.toBeNull(); }); @@ -111,12 +161,31 @@ test.describe('Search Users', () => { const partialRows = await userManagementPage.getUserRows(); expect(partialRows.length).toBeGreaterThan(0); + // Universal negative: every row must contain 'ad' in name or email + for (const row of partialRows) { + const cells = await row.locator('td').allTextContents(); + expect(`${cells[0]} ${cells[1]}`.toLowerCase()).toContain('ad'); + } + + const adminRowPartial = await userManagementPage.getUserRow(TEST_ADMIN.email); + expect(adminRowPartial, `Expected ${TEST_ADMIN.email} in results for "Ad"`).not.toBeNull(); + // When: User continues typing await userManagementPage.searchInput.fill('Admin'); await page.waitForTimeout(400); - // Then: Results should update again + // Then: Results should update again (narrower query → no more rows than before) const fullRows = await userManagementPage.getUserRows(); expect(fullRows.length).toBeGreaterThan(0); + expect(fullRows.length).toBeLessThanOrEqual(partialRows.length); + + // Universal negative: every row must contain 'admin' in name or email + for (const row of fullRows) { + const cells = await row.locator('td').allTextContents(); + expect(`${cells[0]} ${cells[1]}`.toLowerCase()).toContain('admin'); + } + + const adminRowFull = await userManagementPage.getUserRow(TEST_ADMIN.email); + expect(adminRowFull, `Expected ${TEST_ADMIN.email} in results for "Admin"`).not.toBeNull(); }); }); From a3a30e13f3f6e9d07951fb5494e08d4da65cce71 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 11:53:26 +0200 Subject: [PATCH 08/42] fix(e2e): column-visibility E2E coverage + decouple testPrefix from auth (#1966, #1969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix column-visibility E2E coverage for `budget-overview` (7-column variant incl. Status) — Scenario 24 in `reportWizardEditableContent.spec.ts` - Decouple `testPrefix` fixture from `authenticatedPage` dependency so tests not needing auth don't incur the overhead - Add ESLint suppression in auth.ts for Playwright's required empty-destructuring syntax - Positive-control assertion confirms route interceptor fires before "nothing fired" assertion Fixes #1966 Fixes #1969 Co-Authored-By: Claude e2e-test-engineer Co-Authored-By: Claude product-architect --- .../product-architect/recurring-patterns.md | 41 ++++++ .../product-architect/story-reviews.md | 49 +++++++ e2e/fixtures/auth.ts | 5 +- .../reportWizardEditableContent.spec.ts | 137 ++++++++++++++++++ 4 files changed, 231 insertions(+), 1 deletion(-) diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 971bc2c0d..25c44bcf6 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -614,3 +614,44 @@ deactivated users). Consequences for review: `parallelIndex` would make `createLocalUserViaApi`'s `expect(response.ok())` fail and mask the real failure. Require `${testPrefix}-${Date.now()}@e2e-test.local` (precedent: `i18n-categories.spec.ts`). - `deleteUserViaApi` ignores the response status, so cleanup failures in this family are always silent. + +## `page.route()` matcher traps in e2e specs (PR #1986) + +Two independent ways a route-interception assertion becomes vacuous, both invisible to CI: + +1. **`API` is an object map**, not a string (`e2e/fixtures/testData.ts:39`). `` page.route(`${API}/users/me/preferences`) `` interpolates to the glob `[object Object]/users/me/preferences`, which matches nothing — so `expect(captured).toHaveLength(0)` passes forever. The repo convention is `` `**${API.}` `` (property access + `**` prefix); `reportWizardEditableContent.spec.ts:969,998` do it right. ESLint's `restrict-template-expressions` would flag it but **CI runs no ESLint** (`static-analysis` = `npm audit signatures` + `typecheck` + `Stylelint` only). +2. Any **negative** route assertion (`toHaveLength(0)`, `not.toHaveBeenCalled`) is indistinguishable from a broken matcher. Always require the author to prove the matcher fires once (assert `1` against a deliberate request, then invert) before accepting the guard. + +## "Runs at all three viewports" is false unless the test is `@responsive`-tagged + +`e2e/playwright.config.ts`: `tablet` (iPad gen 7, 810px, webkit) and `mobile` (iPhone 13, 390px, webkit) +projects both set `grep: /@responsive/`. An untagged test runs **desktop only** — reject any AC/docstring +claiming multi-viewport coverage without `{ tag: '@responsive' }`. + +Adding the tag is not a free fix when the component has a **dual layout in the DOM**: `ReportContentEditor` +renders both a `` and a `.mobileCardList`, CSS-gated at `@media (max-width: 767px)`. `display: none` +drops the table from the a11y tree, so `getByRole('columnheader')` is 0 at mobile *regardless of state* — +`toHaveCount(0)` passes vacuously and `toHaveCount(1)` fails. Layout-dependent assertions must branch on +viewport (assert `.mobileCardRow` captions at mobile). Scenario 1b in that spec is the precedent guard. + +Related smell from the same PR: a test **title** naming behavior the body never asserts ("reset on remount", +"`
    ` cells" when only `` is checked) — a coverage illusion; trim the title or add the assertions. + +### Accessible-name locators: what they are and are not immune to (#1966 round 3) + +`getByRole(..., { name })` computes the name from **DOM text**, so it is immune to CSS `text-transform` — +the opposite of `innerText`/`toHaveText` assertions, which fail on transformed labels. Prefer the role+name +form when a component may style its casing. + +Two follow-on facts worth reusing: +- An embedded control inside a name-from-content traversal contributes its **value**, not its `aria-label`. + So an `EditableField` whose `ariaLabel` interpolates a neighbouring column's text cannot inflate the + containing cell's accessible name (and `exact: true` guards even if it could). +- `role=cell` / `role=columnheader` exposure depends on the table keeping table semantics — a `display: block` + or `display: flex` on the `` strips them in Chromium and silently zeroes such locators. Before + trusting a new `cell` assertion, confirm a sibling `columnheader` assertion already passes in CI; both rest + on the same exposure. + +Absence assertions need a **positive baseline in the same test** (`toHaveCount(1)` before, `toHaveCount(0)` +after). Without it, a typo'd or mis-scoped locator makes the absence check pass on nothing. With it, every +mis-scoping fails loudly instead — that property is the review bar, not the assertion count. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index 86c196455..26c2da312 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -615,3 +615,52 @@ keeps both `tbody tr` rows and the mobile card list in the DOM, so the loops beh viewports. Three non-blocking follow-ups (loop-vs-seeded-row discriminating power, non-worker-scoped `no-match-` email, positional cell indices vs column preferences) — all recorded in [[recurring-patterns]]. + +## #1966 + #1969 / PR #1986 — column-toggle E2E coverage + testPrefix decoupling + +Round 1 CHANGES_REQUIRED (`${API}` object-interpolation making AC3 vacuous; untagged test claiming +three-viewport coverage; `no-empty-pattern` lint error; over-claiming test title). Round 2 (`9e4b0e57`) +still CHANGES_REQUIRED — but on a gap **my own round-1 review created**, see below. + +### I told them to trim a title when the AC required the assertion (my error) + +Round 1 I wrote "neither `` (ReportContentEditor.tsx:251) ← `vendor: invoice.vendorName` +(buildReportContent.ts:200) ← `vendorName: vendors.name` join (invoiceService.ts:272). + +Remaining non-blocking: unformatted new line (Prettier, invisible to CI on e2e-only PRs), stale AC4 docstring +paragraph, hardcoded preferences glob ×3, #1969 AC2 premise error (product-owner). diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 44132f1ea..d010a0a7f 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -22,8 +22,11 @@ export const test = base.extend<{ // Unique prefix per worker+project to prevent data collisions in shared DB. // Format: "E2E-<3-char-project>" e.g. "E2E-des0", "E2E-tab2", "E2E-mob1" + // No auth dependency: testInfo provides all needed context without forcing a shared-admin + // browser context to be constructed for tests that authenticate as their own isolated user. testPrefix: [ - async ({ authenticatedPage: _ap }, use, testInfo: TestInfo) => { + // eslint-disable-next-line no-empty-pattern -- Playwright infers fixture deps from destructuring; {} is required syntax to declare no deps + async ({}, use, testInfo: TestInfo) => { const project = testInfo.project.name.slice(0, 3); // "des", "tab", "mob" await use(`E2E-${project}${testInfo.workerIndex}`); }, diff --git a/e2e/tests/budget/reportWizardEditableContent.spec.ts b/e2e/tests/budget/reportWizardEditableContent.spec.ts index 0fcaddb96..49c9f98c7 100644 --- a/e2e/tests/budget/reportWizardEditableContent.spec.ts +++ b/e2e/tests/budget/reportWizardEditableContent.spec.ts @@ -2256,3 +2256,140 @@ test.describe('Report wizard editable content — signature field reset (Scenari } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 24: Column-visibility toggles — local state, no persistence (#1966) +// ───────────────────────────────────────────────────────────────────────────── +// +// ReportContentEditor renders a `role="group"` labelled "Show/hide columns" above the summary +// table. Checkboxes control per-column visibility using local `useState` only — the PDF always +// includes every column regardless of toggle state. This scenario asserts: +// AC1: every column checkbox is present and locatable by accessible name; +// AC2: the rendered checkbox count equals the component-defined toggleable-column count; +// AC3: toggling fires no PATCH to /api/users/me/preferences (local state, not persisted); +// AC4: coverage runs at desktop viewport only (no `@responsive` tag) — the toggle group and +// checkboxes are always visible regardless of viewport, but the `
    ` cells nor remount reset is required by AC1-AC4, so the cheap fix is to trim +the title" — without re-reading AC1, which bolds "the corresponding `` **and every matching `**`… +asserts **both** return". They trimmed, as instructed, and the required assertion stayed missing. +**Rule: when a test title over-claims, re-read the AC before recommending the trim.** An over-claiming title +has two fixes and they are not interchangeable — trimming is only correct once you have confirmed no AC +demands the named behavior. Getting this backwards converts a MEDIUM cosmetic finding into a silently +dropped requirement, and costs an extra review round on top. + +### `page.route` does not intercept `page.request.*` + +`page.route` only sees requests from the **browser context**. `page.request.patch()` / any +`APIRequestContext` call bypasses it. So the positive control for a route guard must be +`page.evaluate(() => fetch(...))`, not `page.request.*` — my round-1 fix spec suggested +`page.request.patch()` for exactly this purpose, which would have failed and looked like a broken matcher. +The author correctly used `page.evaluate`. Ordering is deterministic without any wait: the Node-side handler +pushes before `route.continue()`, so the in-page `await fetch` cannot resolve until the capture has happened. + +### Other verified facts from this review + +- `--report-unused-disable-directives` is the cheap way to prove an `eslint-disable` is live rather than + cargo-culted — run it whenever a PR adds a suppression. +- `Detect Changes` **skips `Static Analysis` entirely** on `e2e/`+`.claude/`-only PRs, so on those PRs the + local lint policy is the only lint gate that exists at all (weaker even than the usual "CI runs no ESLint"). +- AC premise error in #1969 AC2: asks that `testPrefix` "values differ" between two tests in one file, but + the value is `E2E-` — identical within a worker despite `{ scope: 'test' }`. + Flagged to product-owner for amendment rather than designed around (cf. the AC-premise-error rule). +- AC4's own suggested rationale ("the mobile card list exposes no column toggles") is factually wrong for + `ReportContentEditor` — the card layout gates every row on the same `show()` predicate. The desktop-only + exclusion is a limitation of the `columnheader` locator under `display: none`, not an absence of toggles. + +### Round 3 (`4cf5a735`) — APPROVED + +The `` gap was fixed the right way: `getByRole('cell', { name: , exact: true })` with a +**baseline `toHaveCount(1)` before the toggle** and `toHaveCount(1)` again after re-checking. That baseline is +what makes the `toHaveCount(0)` non-vacuous — insist on it every time a test asserts an element's absence. +Verified chain: `{row.vendor}` removal assertion +// uses `getByRole('columnheader')` which requires elements in the accessibility tree; +// the table is CSS-hidden on mobile (`max-width: 767px → .table { display: none }`), so +// `columnheader` assertions would fail at mobile. The mobile card layout is tested in +// other scenarios that carry `@responsive`. +// +// Uses `budget-overview` (7 columns incl. Status) to exercise the `content.isOverview` branch +// in ReportContentEditor's column list — a claim report would render 6 columns. + +test.describe('Report wizard editable content — column-visibility toggles, local state (Scenario 24, #1966)', () => { + // toggleable columns for budget-overview in insertion order (matches component source) + const OVERVIEW_COLUMNS = [ + 'Vendor', + 'Invoice No.', + 'Date', + 'Status', + 'Invoice Amount', + 'Allocated Amount', + 'Usage', + ] as const; + const OVERVIEW_COLUMN_COUNT = OVERVIEW_COLUMNS.length; // 7 + + test('Column toggles show/hide column headers and data cells (desktop) and never write to /api/users/me/preferences', async ({ + page, + testPrefix, + }) => { + const wizard = new ReportWizardPage(page); + + let vendorId = ''; + let sourceId = ''; + let workItemId = ''; + try { + vendorId = await createVendorViaApi(page, { name: `${testPrefix} Toggle Vendor` }); + sourceId = await createBudgetSourceViaApi(page, { + name: `${testPrefix} Toggle Source`, + totalAmount: 5000, + // contactAddress + reference required for cover letter to auto-enable on budget-overview + contactAddress: '1 Toggle St, Testville', + reference: 'Ref-TOGGLE', + }); + workItemId = await createWorkItemViaApi(page, { title: `${testPrefix} WI Toggle` }); + await seedAllocatedInvoice(page, workItemId, vendorId, sourceId, { + invoiceNumber: `${testPrefix}-TOG-001`, + amount: 500, + date: '2026-06-01', + status: 'pending', + }); + + await reachStep5(wizard, sourceId, 'budget-overview'); + + // ── AC1 + AC2: group present, every checkbox visible and checked, count matches component ── + const columnGroup = page.getByRole('group', { name: 'Show/hide columns' }); + await expect(columnGroup).toBeVisible(); + + const checkboxes = columnGroup.getByRole('checkbox'); + // AC2: count must equal the component-defined column list length so a future column + // addition fails this test instead of silently going uncovered. + await expect(checkboxes).toHaveCount(OVERVIEW_COLUMN_COUNT); + + // AC1: each column is locatable by its label and checked by default + for (const label of OVERVIEW_COLUMNS) { + await expect(columnGroup.getByLabel(label)).toBeVisible(); + await expect(columnGroup.getByLabel(label)).toBeChecked(); + } + + // ── AC3: intercept preference writes ── + // Note: API is an object (`testData.ts`), so `${API}/...` would expand to + // `[object Object]/...` and never match. Use the glob form instead. + const prefPatches: string[] = []; + await page.route('**/api/users/me/preferences', (route) => { + if (route.request().method() === 'PATCH') prefPatches.push(route.request().url()); + void route.continue(); + }); + + // Positive control: confirm the interceptor fires before relying on "nothing fired". + // A real PATCH issued via page.evaluate() must increment the counter. + await page.evaluate(async () => { + await fetch('/api/users/me/preferences', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + }); + expect( + prefPatches, + 'positive control: interceptor must capture a manually-triggered PATCH', + ).toHaveLength(1); + prefPatches.length = 0; // reset before the actual toggle assertions + + // ── Toggle off: "Vendor" disappears from both table header and data cells ── + // AC1 requires asserting BOTH the and the matching are absent. + // ReportContentEditor uses conditional rendering (`show('vendor') && …` and + // `show('vendor') && …`), so both are removed from the DOM entirely when hidden. + const vendorHeader = page.getByRole('columnheader', { name: 'Vendor', exact: true }); + const vendorCell = page.getByRole('cell', { + name: `${testPrefix} Toggle Vendor`, + exact: true, + }); + + // Baseline: both are present before any toggle + await expect(vendorHeader).toHaveCount(1); + await expect(vendorCell).toHaveCount(1); + + await columnGroup.getByLabel('Vendor').uncheck(); + await expect(columnGroup.getByLabel('Vendor')).not.toBeChecked(); + await expect(vendorHeader).toHaveCount(0); + await expect(vendorCell).toHaveCount(0); + + // ── Toggle back on: both column header and data cell return ── + await columnGroup.getByLabel('Vendor').check(); + await expect(columnGroup.getByLabel('Vendor')).toBeChecked(); + await expect(vendorHeader).toHaveCount(1); + await expect(vendorCell).toHaveCount(1); + + // AC3: no preference PATCH was issued during any column toggle + expect(prefPatches, 'column toggle must not write to /api/users/me/preferences').toHaveLength( + 0, + ); + } finally { + if (workItemId) await deleteWorkItemViaApi(page, workItemId); + if (sourceId) await deleteBudgetSourceViaApi(page, sourceId); + if (vendorId) await deleteVendorViaApi(page, vendorId); + } + }); +}); From ccde679b4f821f378c81e6f5eacee9aacab9edd8 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 13:00:28 +0200 Subject: [PATCH 09/42] fix(server): calendar-drift test fixtures + LLM plain-prose enforcement (#1913, #1952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace expiring `'2027-06-15'`/`'2027-06-01'` absolute date literals in two test files with `futureDateStr(500)` relative helpers — fixtures self-renew every run - Add `stripMarkup()` to LLM response validator: strips bold/italic (CommonMark flanking guards), ATX headings, bullet markers, numbered markers (run-of-≥2 only, preserving German ordinals/dates), and HTML tags from `letterBody`, `letterSubject`, and `descriptions[].description` before length truncation — 46 unit + integration tests at 100% function coverage Fixes #1913 Fixes #1952 Co-Authored-By: Claude backend-developer Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester --- .../agent-memory/product-architect/MEMORY.md | 4 +- .../product-architect/recurring-patterns.md | 85 ++++- .../product-architect/story-reviews.md | 41 +++ server/src/routes/schedule.test.ts | 8 +- .../openAICompatibleProvider.test.ts | 303 ++++++++++++++++++ .../openAICompatibleProvider.ts | 89 ++++- .../services/householdItemDepService.test.ts | 13 +- wiki | 2 +- 8 files changed, 526 insertions(+), 19 deletions(-) diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index cf3a12707..606266d63 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,7 +2,7 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952) - [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 @@ -35,6 +35,8 @@ - Git submodule at `wiki/`. Sync: `git submodule update --init wiki && git -C wiki pull origin master` - Submodule is normally in **detached HEAD** at origin/master — push with `git push origin HEAD:master` +- **Verify published-ness with `git -C wiki ls-remote origin master` vs `git ls-tree HEAD wiki`**, never with + `git -C wiki log` (shows unpushed commits as HEAD) or a refspec-less `fetch` (leaves origin/master stale) - Pages: Architecture, Schema, API-Contract, Home, ADR-Index, ADR-NNN-*, Style-Guide (ux-designer), Security-Audit (security-engineer) - **Always push wiki before creating the PR** — the submodule ref must be committed on the feature branch. If you push wiki content outside the branch, flag that the PR's ref needs bumping. diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 25c44bcf6..f05280593 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -564,7 +564,7 @@ Stylelint. No `format:check`, no ESLint. So `npx prettier --check }` `` (property access + `**` prefix); `reportWizardEditableContent.spec.ts:969,998` do it right. ESLint's `restrict-template-expressions` would flag it but **CI runs no ESLint** (`static-analysis` = `npm audit signatures` + `typecheck` + `Stylelint` only). +1. **`API` is an object map**, not a string (`e2e/fixtures/testData.ts:39`). ``page.route(`${API}/users/me/preferences`)`` interpolates to the glob `[object Object]/users/me/preferences`, which matches nothing — so `expect(captured).toHaveLength(0)` passes forever. The repo convention is `` `**${API.}` `` (property access + `**` prefix); `reportWizardEditableContent.spec.ts:969,998` do it right. ESLint's `restrict-template-expressions` would flag it but **CI runs no ESLint** (`static-analysis` = `npm audit signatures` + `typecheck` + `Stylelint` only). 2. Any **negative** route assertion (`toHaveLength(0)`, `not.toHaveBeenCalled`) is indistinguishable from a broken matcher. Always require the author to prove the matcher fires once (assert `1` against a deliberate request, then invert) before accepting the guard. ## "Runs at all three viewports" is false unless the test is `@responsive`-tagged @@ -630,7 +630,7 @@ claiming multi-viewport coverage without `{ tag: '@responsive' }`. Adding the tag is not a free fix when the component has a **dual layout in the DOM**: `ReportContentEditor` renders both a `` and a `.mobileCardList`, CSS-gated at `@media (max-width: 767px)`. `display: none` -drops the table from the a11y tree, so `getByRole('columnheader')` is 0 at mobile *regardless of state* — +drops the table from the a11y tree, so `getByRole('columnheader')` is 0 at mobile _regardless of state_ — `toHaveCount(0)` passes vacuously and `toHaveCount(1)` fails. Layout-dependent assertions must branch on viewport (assert `.mobileCardRow` captions at mobile). Scenario 1b in that spec is the precedent guard. @@ -644,6 +644,7 @@ the opposite of `innerText`/`toHaveText` assertions, which fail on transformed l form when a component may style its casing. Two follow-on facts worth reusing: + - An embedded control inside a name-from-content traversal contributes its **value**, not its `aria-label`. So an `EditableField` whose `ariaLabel` interpolates a neighbouring column's text cannot inflate the containing cell's accessible name (and `exact: true` guards even if it could). @@ -655,3 +656,79 @@ Two follow-on facts worth reusing: Absence assertions need a **positive baseline in the same test** (`toHaveCount(1)` before, `toHaveCount(0)` after). Without it, a typo'd or mis-scoped locator makes the absence check pass on nothing. With it, every mis-scoping fails loudly instead — that property is the review bar, not the assertion count. + +### Single-occurrence guard tests prove nothing about delimiter pairing (#1952, PR #1987) + +A "false-positive guard" test that feeds the sanitizer **one** unpaired delimiter cannot detect that the +regex pairs up **two** unpaired ones. `stripMarkup`'s guards were `'value_field without close'` (one `_`) +and `'Price: 5 EUR* (VAT incl.)'` (one `*`) — both green while `budget_line_id` -> `budgetlineid`, +`RE_2024_117` -> `RE2024117`, and `'5 EUR* … 10%* …'` -> both stripped. When reviewing any +strip/sanitize/unescape regex, the question is not "is there a guard test?" but **"is there a guard test with +two or more of the delimiter on one line?"** + +The fix is CommonMark's flanking rules, and they are the right reference for any markdown-ish stripper: +opening delimiter must be followed by non-space, closing preceded by non-space, and `_` must additionally +not be intraword (CommonMark disables intraword `_` emphasis precisely because of snake_case and e-mails): +`/(?=2 consecutive numbered lines** +(a lone marker is an ordinal, not a list). Bullet markers (`- `/`* `/`+ `) stay unconditional — a leading +`- ` is not idiomatic prose. Document the asymmetry in the JSDoc so a later reader does not "harmonize" it. + +### Two ACs in direct tension, silently resolved (#1952 AC 1.2 vs AC 2.5) + +AC 1.2 mandated stripping `1. `/`1) `; AC 2.5 mandated compliant plain prose pass through **byte-identical**. +These cannot both hold for German prose. The implementation picked 1.2 without recording the trade-off. +When an issue states a preference ordering in prose ("conservative stripping matters more than exhaustive +stripping… when in doubt, leave the text alone"), that prose **is** the tie-breaker — read the issue's +narrative sections, not just the checkbox list, before accepting an AC-satisfying implementation. + +### Validate a proposed regex fix before writing it into the review + +For any non-trivial regex fix spec, transcribe the current + proposed implementation into a throwaway +`/tmp/*.mjs`, and assert the proposed version against (a) every existing test case, (b) the new false +positives, and (c) the issue's Verification scenarios. PR #1987's spec was validated 45/45 this way, which +turns "here is a suggestion" into "here is a drop-in that breaks no existing test" — the difference between +one fix round and three. + +### Strip-order tests need a `toBe`, not just a `toHaveLength` + +To pin "strip runs before truncate", the boundary fixture `'**' + 'X'.repeat(limit) + '**'` yields a +`limit`-length string under **both** orderings (`'X'.repeat(limit)` vs `'**XXX…'`). Only the `toBe` assertion +discriminates. Good pattern to reuse; also a reminder that a length assertion alone is often vacuous. + +### A bumped submodule ref is not a pushed wiki commit (PR #1987) + +PR #1987 had the parent ref bumped to a wiki commit that was **never pushed** — the wiki remote was two +commits behind. `git -C wiki log --oneline` shows the commit as HEAD, so the wiki *looks* published, and +`git ls-tree HEAD wiki` matches it, so the ref *looks* correct. Anyone cloning the branch and running +`git submodule update` would fail on an unresolvable ref. + +Verify with `git -C wiki ls-remote origin master` compared against `git ls-tree HEAD wiki` — those are the +only two facts that matter. **Do not** trust `git -C wiki fetch origin master` here: without an explicit +refspec it only writes `FETCH_HEAD` and leaves `refs/remotes/origin/master` stale, which initially made the +remote look already-current. Use `git -C wiki fetch origin master:refs/remotes/origin/master`, or `ls-remote`. + +Add this to every PR review touching `wiki/`. "The ref is bumped on the branch" is a weaker claim than it +sounds — I asserted AC 4.1 satisfied on that basis before catching it. + +### Shared worktrees: never `git add -A` (PR #1987) + +`fix-1913-1952-server-tests` had ~48 dirty files from concurrent agents plus the known repo-wide prettier +union-type drift (`shared/src/types/{dependency,diary,document,subsidyProgram}.ts` — the same four every +time). Stage explicit paths only: for a wiki/ADR change that is `git add wiki .claude/agent-memory/` +and nothing else. A scoped ref-bump commit does not disturb an implementer mid-edit in the same tree. + +### Shell heredocs: a bare `cat >> file` with no redirect hangs the tool + +`cat >> a.md 2>/dev/null || true` followed by a second `cat >> b.md <<'EOF'` — the heredoc binds to the +*second* cat, so the first reads stdin and blocks until the 120s timeout. Prefer the Edit/Write tools for +appending to memory files; if you must use bash, one heredoc per command and never a redirect-less `cat`. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index 26c2da312..e9c12ded1 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -664,3 +664,44 @@ Verified chain: `` (ReportContentEditor.tsx:251) ← `vendo Remaining non-blocking: unformatted new line (Prettier, invisible to CI on e2e-only PRs), stale AC4 docstring paragraph, hardcoded preferences glob ×3, #1969 AC2 premise error (product-owner). + +## PR #1987 (#1913 + #1952) — CHANGES_REQUIRED (round 1, `23c35371`) + +`fix(server): calendar-drift test fixtures + LLM plain-prose enforcement`. Review posted via +`gh pr comment` (self-authored PR blocks `--request-changes`). + +**#1913 clean.** `futureDateStr(500)` uses real `new Date()` — no fake timers, per #1913's explicit ban +(they poison `schedulingEngine.ts`'s module-level `lastRescheduleDate` gate). Both checklist sites hit; +`insertWorkItem` defaults to `not_started` so both are genuinely CPM-today-floor-sensitive. The surviving +`'2027-06-15'` at `householdItemDepService.test.ts:186/208` is correctly left alone — `in_progress` **and** +a `listDeps` read-back with no scheduler in the path, so it cannot expire. + +**#1952 — 2 HIGH false positives** in `stripMarkup`, both violating AC 2.5's byte-identical guarantee: +intraword `_` mangling reference numbers/e-mails, and line-start `\d+[.)] ` eating German ordinals and +dates. Plus MEDIUM: two unpaired `*` on one line pairing up; AC 3.2 (`'- Pos. 3 - Dachstuhl'`) untested. +See [[recurring-patterns]] for the generalized rules — single-occurrence guard tests, German ordinals, +AC-tension, and pre-validating regex fix specs. + +Structure/integration were all correct and worth noting as the good half: strip-before-truncate at all +three call sites, `LlmInvalidResponseError` paths untouched (strip runs after the type/non-empty guards and +the empty-fallback makes it incapable of emptying a valid field), prompt rule 4 preserved (AC 3.3), wiki +amended not deleted with the submodule ref bumped on-branch (AC 4.1). 193/193 + 187/187 green locally. + +Non-blocking: `futureDateStr` now triplicated (`timeline.test.ts:151` + 2 copies) while +`server/src/test-helpers/` exists — and the two new copies dropped the JSDoc that carries the *reason* +(CPM today-floor on `not_started`), i.e. exactly the knowledge #1913 was filed to preserve. + +### Round 2 (`857fcedd`) — APPROVED + +All six findings fixed; shipped regexes **byte-identical** to the 45/45-validated spec (diffed, not eyeballed). +207/207 pass. Test file `--numstat` = `80 0`, so no existing assertion was weakened to fit the new behaviour. + +The technique worth reusing: **prove non-vacuity by mutation, not inspection.** I replayed all 15 new +scenarios against the round-1 implementation — 13/15 fail against it. The 2 that pass under both are the +deliberate regression guards (AC 3.2 bullet+hyphen, genuine numbered run), so passing either way is their +intended property. That split is the evidence an approval should rest on; "N tests added" is not. + +Accepted residuals, recorded so they are not rediscovered as bugs: `` pseudo-tags are still +stripped (INFO-1, AC 2.4's `Beträge < 500 EUR` safe via the space-after-`<` guard); a genuine German date +list of ≥2 lines (`15. Mai: …\n16. Mai: …`) still loses its numbers, which is arguably correct (INFO-2). +`futureDateStr` extraction to `server/src/test-helpers/dates.ts` deferred as a follow-up. diff --git a/server/src/routes/schedule.test.ts b/server/src/routes/schedule.test.ts index d9181945b..06c26e8ae 100644 --- a/server/src/routes/schedule.test.ts +++ b/server/src/routes/schedule.test.ts @@ -9,6 +9,12 @@ import type { FastifyInstance } from 'fastify'; import type { ScheduleResponse, ApiErrorResponse, ScheduleRequest } from '@cornerstone/shared'; import { workItems, workItemDependencies } from '../db/schema.js'; +function futureDateStr(daysFromNow: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + daysFromNow); + return d.toISOString().slice(0, 10); +} + describe('Schedule Routes', () => { let app: FastifyInstance; let tempDir: string; @@ -764,7 +770,7 @@ describe('Schedule Routes', () => { 'Test User', 'password123', ); - const futureDate = '2027-06-01'; + const futureDate = futureDateStr(500); const wiId = createTestWorkItem(userId, 'Future Task', { durationDays: 5, startAfter: futureDate, diff --git a/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts b/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts index 72bd6bb25..b4dbc860c 100644 --- a/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts +++ b/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts @@ -19,6 +19,7 @@ import { validateExtractedLines, validateMergeResult, validateGenerateReportContentResult, + stripMarkup, } from './openAICompatibleProvider.js'; import { LlmUnreachableError, @@ -1827,6 +1828,77 @@ describe('createOpenAICompatibleProvider — generateReportContent() failure mod // ─── Story #1901: validateGenerateReportContentResult() ───────────────────── describe('validateGenerateReportContentResult()', () => { + // ─── Story #1952: markup stripping integration ──────────────────────────── + // These tests verify that stripMarkup is applied inside validateGenerateReportContentResult + // before length capping and before the result is returned. + describe('markup stripping — Story #1952', () => { + it('strips **bold** from letterSubject before returning', () => { + const result = validateGenerateReportContentResult( + { + letterSubject: '**Construction** Project Report', + letterBody: 'Body', + descriptions: [{ invoiceId: 'inv-1', description: 'Desc' }], + }, + ['inv-1'], + ); + expect(result.letterSubject).toBe('Construction Project Report'); + }); + + it('strips bullet list from letterBody before returning', () => { + const result = validateGenerateReportContentResult( + { + letterSubject: 'Subject', + letterBody: '- Point A\n- Point B', + descriptions: [{ invoiceId: 'inv-1', description: 'Desc' }], + }, + ['inv-1'], + ); + expect(result.letterBody).toBe('Point A\nPoint B'); + }); + + it('strips HTML from descriptions[].description before returning', () => { + const result = validateGenerateReportContentResult( + { + letterSubject: 'Subject', + letterBody: 'Body', + descriptions: [{ invoiceId: 'inv-1', description: 'Foundation work completed' }], + }, + ['inv-1'], + ); + expect(result.descriptions['inv-1']).toBe('Foundation work completed'); + }); + + it('markup is stripped before truncation — stripped length determines the cap', () => { + // Input letterSubject is '**' + 'S'.repeat(limit) + '**', which is limit+4 chars. + // After stripping the bold markers the inner text is exactly `limit` chars (at the boundary). + // If truncation were applied to the raw (pre-strip) string, it would cut into the markers and + // the result would be shorter than the limit. If strip happens first, result === 'S'.repeat(limit). + const result = validateGenerateReportContentResult( + { + letterSubject: '**' + 'S'.repeat(REPORT_CONTENT_LIMITS.letterSubject) + '**', + letterBody: 'Body', + descriptions: [{ invoiceId: 'inv-1', description: 'Desc' }], + }, + ['inv-1'], + ); + expect(result.letterSubject).toHaveLength(REPORT_CONTENT_LIMITS.letterSubject); + expect(result.letterSubject).toBe('S'.repeat(REPORT_CONTENT_LIMITS.letterSubject)); + }); + + it('fallback: letterBody preserved when stripping yields whitespace-only', () => { + // '** **' strips to ' ' (whitespace-only) → stripMarkup returns original '** **' + const result = validateGenerateReportContentResult( + { + letterSubject: 'Subject', + letterBody: '** **', + descriptions: [{ invoiceId: 'inv-1', description: 'Desc' }], + }, + ['inv-1'], + ); + expect(result.letterBody).toBe('** **'); + }); + }); + describe('valid inputs', () => { it('validates a minimal valid result', () => { const result = validateGenerateReportContentResult( @@ -2188,3 +2260,234 @@ describe('validateGenerateReportContentResult()', () => { }); }); }); + +// ─── Story #1952: stripMarkup() — markup sanitization ──────────────────────── + +describe('stripMarkup() — markup sanitization (Story #1952)', () => { + // ── Happy path: markup is removed ───────────────────────────────────────── + + it('returns plain text unchanged — no markup chars', () => { + const input = 'Dear Bank Officer,\n\nPlease find our report.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('strips **bold** markers', () => { + expect(stripMarkup('The **key** figure is 5000 EUR.')).toBe('The key figure is 5000 EUR.'); + }); + + it('strips __bold__ markers', () => { + expect(stripMarkup('This __matters__.')).toBe('This matters.'); + }); + + it('strips *italic* markers', () => { + expect(stripMarkup('Amount *increased* significantly.')).toBe( + 'Amount increased significantly.', + ); + }); + + it('strips _italic_ markers', () => { + expect(stripMarkup('Amount _decreased_ slightly.')).toBe('Amount decreased slightly.'); + }); + + it('strips # ATX heading at line start', () => { + expect(stripMarkup('# Project Report\nBody text.')).toBe('Project Report\nBody text.'); + }); + + it('strips ## through ###### ATX headings at line start', () => { + expect(stripMarkup('## Section\n### Sub-section')).toBe('Section\nSub-section'); + }); + + it('strips leading - list marker — does not merge lines', () => { + expect(stripMarkup('- First item\n- Second item')).toBe('First item\nSecond item'); + }); + + it('strips leading * list marker at line start', () => { + expect(stripMarkup('* Item one\n* Item two')).toBe('Item one\nItem two'); + }); + + it('strips leading + list marker at line start', () => { + expect(stripMarkup('+ Line A')).toBe('Line A'); + }); + + it('strips leading numbered list marker with period (N.)', () => { + expect(stripMarkup('1. First\n2. Second')).toBe('First\nSecond'); + }); + + it('strips leading numbered list marker with parenthesis (N))', () => { + expect(stripMarkup('1) First\n2) Second')).toBe('First\nSecond'); + }); + + it('strips and HTML tags — keeps inner text', () => { + expect(stripMarkup('bold content')).toBe('bold content'); + }); + + it('strips
    self-closing HTML tag', () => { + expect(stripMarkup('line1
    line2')).toBe('line1line2'); + }); + + it('strips

    and

    HTML tags', () => { + expect(stripMarkup('

    paragraph text

    ')).toBe('paragraph text'); + }); + + it('strips nested HTML tags — processes all matches', () => { + expect(stripMarkup('Important and noted')).toBe('Important and noted'); + }); + + it('bold + italic combination both stripped', () => { + expect(stripMarkup('**bold** and *italic* text')).toBe('bold and italic text'); + }); + + it('heading + list combo — both stripped, line structure preserved', () => { + expect(stripMarkup('# Summary\n- Point A\n- Point B')).toBe('Summary\nPoint A\nPoint B'); + }); + + // ── False-positive guards: must NOT strip ───────────────────────────────── + + it('preserves mid-line hyphen (Pos. 3 - Dachstuhl)', () => { + const input = 'Pos. 3 - Dachstuhl'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves date-string hyphens (2024-117)', () => { + const input = 'Rechnung 2024-117 liegt vor.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves Mo-Fr abbreviation', () => { + const input = 'Arbeitszeit Mo-Fr 08:00-17:00'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves # inside a line (not at line start)', () => { + const input = 'Rechnung #2024-117'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves lone * with no matching partner', () => { + const input = 'Price: 5 EUR* (VAT incl.)'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves lone _ with no matching partner', () => { + const input = 'value_field without close'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves < not opening a well-formed tag (Beträge < 500 EUR)', () => { + const input = 'Beträge < 500 EUR'; + expect(stripMarkup(input)).toBe(input); + }); + + it('passes \\n and \\n\\n paragraph breaks through unchanged', () => { + const input = 'Paragraph 1.\n\nParagraph 2.\nContinued.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('passes German umlauts, ß, and € through unchanged', () => { + const input = 'Über die Maßnahmen: Straße kostet 5.000 €.'; + expect(stripMarkup(input)).toBe(input); + }); + + // ── Edge cases ───────────────────────────────────────────────────────────── + + it('returns original when stripping leaves empty string', () => { + // '* ' matches the list-marker regex and strips to ''; fallback returns original + expect(stripMarkup('* ')).toBe('* '); + }); + + it('returns original when stripping leaves whitespace-only', () => { + // '** **' bold-wraps a space; stripped inner is ' '; fallback returns original + expect(stripMarkup('** **')).toBe('** **'); + }); + + it('empty string input returns empty string', () => { + // '' trims to '' which equals ''; fallback returns original '' + expect(stripMarkup('')).toBe(''); + }); + + it('multi-line body with mixed markup — strips all, preserves line breaks', () => { + expect(stripMarkup('## Report\n\n- Item 1\n- Item 2\n\nTotal: 5000 EUR')).toBe( + 'Report\n\nItem 1\nItem 2\n\nTotal: 5000 EUR', + ); + }); + + // ── AC 3.2: markup and legitimate punctuation on the same line ───────────── + + it('strips only the leading bullet and preserves the mid-line hyphen (AC 3.2)', () => { + expect(stripMarkup('- Pos. 3 - Dachstuhl')).toBe('Pos. 3 - Dachstuhl'); + }); + + // ── Intraword underscores are not emphasis (CommonMark) ─────────────────── + + it('preserves snake_case identifiers with two or more underscores', () => { + const input = 'Feld budget_line_id wurde geprüft'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves underscore-separated reference numbers (RE_2024_117)', () => { + const input = 'Rechnung RE_2024_117 vom 3. Mai'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves underscores in an e-mail local part', () => { + const input = 'E-Mail: max_mustermann_bau@example.com'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves underscores between digits (4_5 … 6_7)', () => { + const input = 'Rate: 4_5 Prozent und 6_7 Prozent'; + expect(stripMarkup(input)).toBe(input); + }); + + // ── Whitespace-flanked / unpaired asterisks are not emphasis ────────────── + + it('preserves two footnote asterisks on one line', () => { + const input = 'Preis 5 EUR* zzgl. MwSt, Rabatt 10%* auf Position 4'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves asterisks used as multiplication signs', () => { + const input = 'Die Kosten für Position 3 * 2 Einheiten * 5 EUR ergeben 30 EUR'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves spaced asterisks in arithmetic prose', () => { + const input = '2024 * 12 = Monate, 5 * 3 = 15'; + expect(stripMarkup(input)).toBe(input); + }); + + // ── German ordinals and dates are prose, not lists ──────────────────────── + + it('preserves a German date opening a line (15. Mai 2026)', () => { + const input = '15. Mai 2026 wurde die Rechnung gestellt.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves a German ordinal opening a line (2. Rate)', () => { + const input = '2. Rate in Höhe von 12.000 EUR ist fällig.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves a lone ordinal opening a paragraph after a blank line', () => { + const input = 'Sehr geehrte Damen und Herren,\n\n1. Bauabschnitt ist fertig.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('preserves a lone ordinal mid-body (3. Bauabschnitt)', () => { + const input = 'Die Rechnung ging ein.\n\n3. Bauabschnitt: Dachstuhl abgeschlossen.'; + expect(stripMarkup(input)).toBe(input); + }); + + it('still strips a genuine numbered run of three lines', () => { + expect(stripMarkup('1. Erster Punkt\n2. Zweiter Punkt\n3. Dritter Punkt')).toBe( + 'Erster Punkt\nZweiter Punkt\nDritter Punkt', + ); + }); + + // ── No trailing whitespace left behind by tag removal ───────────────────── + + it('leaves no trailing whitespace after removing a trailing tag', () => { + expect(stripMarkup('Text
    ')).toBe('Text'); + expect(stripMarkup('Bericht fertig ')).toBe('Bericht fertig'); + }); +}); diff --git a/server/src/services/budgetExtraction/openAICompatibleProvider.ts b/server/src/services/budgetExtraction/openAICompatibleProvider.ts index 81f9a24e2..bfe16ebf7 100644 --- a/server/src/services/budgetExtraction/openAICompatibleProvider.ts +++ b/server/src/services/budgetExtraction/openAICompatibleProvider.ts @@ -302,6 +302,74 @@ export function validateMergeResult(body: unknown): MergeLinesLlmResult { }; } +/** Line-start numbered-list marker (`1. ` / `1) `). Also matches a German ordinal, so see below. */ +const NUMBERED_MARKER = /^(\d+[.)]) /; + +/** + * Strips unambiguous markdown and HTML markup from an LLM-generated text field, + * applied before length truncation (Story #1952). + * + * Strips: **bold**, __bold__, *italic*, _italic_ (genuinely flanked markers only), + * ATX heading markers at line-start (# through ######), leading bullet markers at + * line-start (- , * , + ), numbered markers at line-start (N. , N) ) when part of a + * run of two or more consecutive numbered lines, and HTML tags (, ,
    ). + * + * Does NOT strip: + * - mid-line hyphens (`Mo-Fr`, `2024-117`, `Pos. 3 - Dachstuhl`) + * - `#` inside a line (`Rechnung #2024-117`) + * - intraword `_` — `budget_line_id`, `RE_2024_117`, and e-mail local parts keep every + * underscore, matching CommonMark, which disables intraword `_` emphasis outright + * - whitespace-flanked or unpaired `*` — footnote markers (`5 EUR* … 10%* …`) and + * multiplication (`3 * 2 Einheiten`) survive, because a real emphasis opener is + * followed by a non-space and a real closer is preceded by one + * - a lone line-start numbered marker: in German a trailing period marks ordinals and + * dates, so `15. Mai 2026 …` and `2. Rate in Höhe von …` are prose, not lists, and + * dropping the number would silently alter a bank-facing document + * - `<` not opening a well-formed tag (`Beträge < 500 EUR`) + * - `\n` / `\n\n` paragraph breaks, German umlauts, ß, € + * + * Bullet stripping is unconditional while numbered stripping is run-gated. The asymmetry + * is deliberate: a leading `- ` is not idiomatic German prose, a leading `2. ` is. + * + * If stripping would leave the text empty or whitespace-only, returns the original text + * unchanged (losing the entire body is worse than printing markers). + */ +export function stripMarkup(text: string): string { + let result = text; + + // Emphasis — CommonMark-style flanking guards. The opening delimiter must be followed + // by a non-space and the closing one preceded by a non-space; `_` must additionally not + // be intraword. Double markers run before single ones to avoid partial matches. + result = result.replace(/\*\*(?=\S)([^*\n]*[^\s*]|\S)\*\*/g, '$1'); + result = result.replace(/(?=2 consecutive numbered lines, so a lone + // German ordinal or date at a line start is left intact. + const lines = result.split('\n'); + const isNumbered = lines.map((line) => NUMBERED_MARKER.test(line)); + result = lines + .map((line, i) => + isNumbered[i] === true && (isNumbered[i - 1] === true || isNumbered[i + 1] === true) + ? line.replace(NUMBERED_MARKER, '') + : line, + ) + .join('\n'); + + // HTML tags — remove open/close/self-closing; keep inner content + result = result.replace(/<\/?[a-zA-Z][a-zA-Z0-9]*(?:\s[^>]*)?\/?>/g, ''); + + const trimmed = result.trim(); + return trimmed === '' ? text : trimmed; +} + /** * Validates that an unknown value conforms to GenerateReportContentLlmResult schema. * Validates structure, length caps, and presence of all requested invoice IDs. @@ -328,20 +396,22 @@ export function validateGenerateReportContentResult( throw new LlmInvalidResponseError('LLM response missing or invalid "letterSubject"'); } const trimmedSubject = obj.letterSubject.trim(); + const strippedSubject = stripMarkup(trimmedSubject); const letterSubject = - trimmedSubject.length > REPORT_CONTENT_LIMITS.letterSubject - ? trimmedSubject.slice(0, REPORT_CONTENT_LIMITS.letterSubject) - : trimmedSubject; + strippedSubject.length > REPORT_CONTENT_LIMITS.letterSubject + ? strippedSubject.slice(0, REPORT_CONTENT_LIMITS.letterSubject) + : strippedSubject; // Validate letterBody (non-empty string, max REPORT_CONTENT_LIMITS.letterBody chars) if (typeof obj.letterBody !== 'string' || obj.letterBody.trim() === '') { throw new LlmInvalidResponseError('LLM response missing or invalid "letterBody"'); } const trimmedBody = obj.letterBody.trim(); + const strippedBody = stripMarkup(trimmedBody); const letterBody = - trimmedBody.length > REPORT_CONTENT_LIMITS.letterBody - ? trimmedBody.slice(0, REPORT_CONTENT_LIMITS.letterBody) - : trimmedBody; + strippedBody.length > REPORT_CONTENT_LIMITS.letterBody + ? strippedBody.slice(0, REPORT_CONTENT_LIMITS.letterBody) + : strippedBody; // Validate descriptions (array of {invoiceId, description}) if (!Array.isArray(obj.descriptions)) { @@ -371,10 +441,11 @@ export function validateGenerateReportContentResult( const invoiceId = entry.invoiceId.trim(); const trimmedDesc = entry.description.trim(); + const strippedDesc = stripMarkup(trimmedDesc); const cappedDesc = - trimmedDesc.length > REPORT_CONTENT_LIMITS.description - ? trimmedDesc.slice(0, REPORT_CONTENT_LIMITS.description) - : trimmedDesc; + strippedDesc.length > REPORT_CONTENT_LIMITS.description + ? strippedDesc.slice(0, REPORT_CONTENT_LIMITS.description) + : strippedDesc; descriptions[invoiceId] = cappedDesc; foundInvoiceIds.add(invoiceId); } diff --git a/server/src/services/householdItemDepService.test.ts b/server/src/services/householdItemDepService.test.ts index b9275b4b7..231cca549 100644 --- a/server/src/services/householdItemDepService.test.ts +++ b/server/src/services/householdItemDepService.test.ts @@ -19,6 +19,12 @@ import * as schema from '../db/schema.js'; import * as householdItemDepService from './householdItemDepService.js'; import { NotFoundError, ConflictError } from '../errors/AppError.js'; +function futureDateStr(daysFromNow: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + daysFromNow); + return d.toISOString().slice(0, 10); +} + // ─── Test setup ──────────────────────────────────────────────────────────────── function createTestDb() { @@ -267,7 +273,8 @@ describe('householdItemDepService', () => { it('creates a work_item dependency with default FS type and 0 lag', () => { const userId = insertUser(db); const hiId = insertHouseholdItem(db); - const wiId = insertWorkItem(db, userId, { title: 'Foundation Work', endDate: '2027-06-15' }); + const futureEndDate = futureDateStr(500); + const wiId = insertWorkItem(db, userId, { title: 'Foundation Work', endDate: futureEndDate }); const result = householdItemDepService.createDep(db, hiId, { predecessorType: 'work_item', @@ -279,7 +286,7 @@ describe('householdItemDepService', () => { expect(result.predecessorId).toBe(wiId); expect(result.predecessor.id).toBe(wiId); expect(result.predecessor.title).toBe('Foundation Work'); - expect(result.predecessor.endDate).toBe('2027-06-15'); + expect(result.predecessor.endDate).toBe(futureEndDate); }); it('creates a milestone dependency with default FS type and 0 lag', () => { @@ -397,7 +404,7 @@ describe('householdItemDepService', () => { it('calls autoReschedule after creating dep — delivery dates updated in DB', () => { const userId = insertUser(db); const hiId = insertHouseholdItem(db, { status: 'planned' }); - const wiId = insertWorkItem(db, userId, { endDate: '2027-06-15' }); + const wiId = insertWorkItem(db, userId, { endDate: futureDateStr(500) }); // Before creating dep, earliest_delivery_date should be null const _before = db diff --git a/wiki b/wiki index e74496988..076816ea4 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit e744969887f8e7105287641528ac4ae8ec295529 +Subproject commit 076816ea472b5fdf4f9555c8575aed04211b6324 From 217cb408a651f50d090b462bacd8c2d8b2d04c49 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 14:40:54 +0200 Subject: [PATCH 10/42] fix(reports): remove dead attachmentsNote override and refactor ReportWizardPage to useReducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **#1967** — Remove dead `attachmentsNote` override from `applyOverrides.ts` and `overrideKeys.ts`; field has been static read-only text since PR #1959, no UI path can produce its override key; adds regression test pinning the removal - **#1947** — Refactor `ReportWizardPage` from 38 `useState`/`useRef` hooks to a `useReducer` state machine in `wizardReducer.ts`; staleness now enforced via opaque request-id tokens (M1/M2 fixes); named tier types with explicit factory return-type annotations enforce AC4 at compile time; 57 unit tests at 100% coverage; behaviour-preserving (existing tests unchanged per AC3) Fixes #1967 Fixes #1947 Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester --- .../agent-memory/product-architect/MEMORY.md | 2 +- .../product-architect/recurring-patterns.md | 60 +- .../product-architect/story-reviews.md | 29 +- .../story-1947-wizardreducer.md | 21 + .../lib/reportContent/applyOverrides.test.ts | 42 +- .../src/lib/reportContent/applyOverrides.ts | 5 +- .../lib/reportContent/overrideKeys.test.ts | 12 +- client/src/lib/reportContent/overrideKeys.ts | 1 - client/src/lib/reportContent/types.ts | 5 +- .../ReportWizardPage/ReportWizardPage.tsx | 372 +++----- .../ReportWizardPage/wizardReducer.test.ts | 893 ++++++++++++++++++ .../pages/ReportWizardPage/wizardReducer.ts | 297 ++++++ 12 files changed, 1454 insertions(+), 285 deletions(-) create mode 100644 .claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md create mode 100644 client/src/pages/ReportWizardPage/wizardReducer.test.ts create mode 100644 client/src/pages/ReportWizardPage/wizardReducer.ts diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 606266d63..3ce668168 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,7 +2,7 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952) +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952), `Pick<>` is not a forcing function + caller-supplied monotonic seq reintroduces the ref + cascade tables smuggle behaviour changes + neutralised-trigger-left-in-code (#1947), tier factory only forces the cases that spread it (#1988) - [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 diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index f05280593..4f5de792b 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -708,8 +708,8 @@ discriminates. Good pattern to reuse; also a reminder that a length assertion al ### A bumped submodule ref is not a pushed wiki commit (PR #1987) PR #1987 had the parent ref bumped to a wiki commit that was **never pushed** — the wiki remote was two -commits behind. `git -C wiki log --oneline` shows the commit as HEAD, so the wiki *looks* published, and -`git ls-tree HEAD wiki` matches it, so the ref *looks* correct. Anyone cloning the branch and running +commits behind. `git -C wiki log --oneline` shows the commit as HEAD, so the wiki _looks_ published, and +`git ls-tree HEAD wiki` matches it, so the ref _looks_ correct. Anyone cloning the branch and running `git submodule update` would fail on an unresolvable ref. Verify with `git -C wiki ls-remote origin master` compared against `git ls-tree HEAD wiki` — those are the @@ -730,5 +730,59 @@ and nothing else. A scoped ref-bump commit does not disturb an implementer mid-e ### Shell heredocs: a bare `cat >> file` with no redirect hangs the tool `cat >> a.md 2>/dev/null || true` followed by a second `cat >> b.md <<'EOF'` — the heredoc binds to the -*second* cat, so the first reads stdin and blocks until the 120s timeout. Prefer the Edit/Write tools for +_second_ cat, so the first reads stdin and blocks until the 120s timeout. Prefer the Edit/Write tools for appending to memory files; if you must use bash, one heredoc per command and never a redirect-less `cat`. + +### `Pick` is not a forcing function (#1947 action-set review) + +A reducer "tier factory" typed `function freshTier(): Pick` claims to make +"what does this transition clear" a compile-time decision. It does not: adding a field to `State` +produces **no error** — the key union just doesn't mention it, the spread leaves it untouched, and it +silently defaults to _kept_. The key union is a second hand-maintained list, i.e. the very thing being +replaced. The working version is a **named tier type** (`interface ReportTier {...}`) whose factory has an +**explicit return-type annotation** and returns a total object literal — missing property = compile error. +The annotation is load-bearing: an inferred return type re-derives the shape from the literal and the +error vanishes. Partition state as a flat intersection of tiers, not nested objects (nesting churns every +read site). Generalises to any "exhaustive mapping" claim made with `Pick`/`Omit`/`Record`. + +### Caller-supplied monotonic seq in an action payload reintroduces the ref it replaces (#1947) + +`dispatch({type:'SELECT_SOURCE', payload:{ newReportSeq }})` asks the caller to produce a value that must +stay **in sync with reducer-owned state** — only achievable with an out-of-reducer counter ref, so the +"staleness is enforced in the reducer" claim is false. Fix: **opaque nullable token** (`requestId: string | +null`) used as identity, never ordering — caller generates via a module-level `nextRequestId()`, echoes it +back in the completion action, reducer no-ops on mismatch. `null` then means "nothing in flight, discard +every outstanding response", so a reset invalidates in-flight work with no bump arithmetic. Monotonicity is +never needed when nothing compares generations for order. Corollary: an in-flight **boolean flag** +(`isGeneratingAi`) alongside such a token must be **derived** (`token !== null`), never stored — the two +disagreeing is exactly the bug class the token exists to kill. + +### A refactor's cascade table smuggles behaviour changes (#1947) + +Diff every row of a proposed reset/cascade table against the actual handler line-by-line. Two of three rows +in #1947's table cleared `aiError` where the code does not: one handler never clears it, and the other +clears it only inside `if (isGeneratingAi)`. Both were reachable, user-visible, and would have landed inside +a PR whose stated AC was "no user-visible change". Also watch for **generic setter actions** +(`SET_MAX_STEP`) — a setter wearing an action's clothes preserves the ad-hoc call it was meant to replace +and names nothing about what it invalidates. And check whether the _unfixed_ instances of the same race +exist elsewhere in the file (#1947 had a third, unguarded, in the Step-2 fan-out fetch). + +### The neutralised trigger left in the code (#1947 `deepLinkAppliedRef`) + +When a defect's trigger condition is _neutralised by a new guard_ rather than removed, the guarantee lives +in a comment. `if (… && !report && !appliedRef.current)` — `!report` was the AC8 trigger, kept alive behind +a ref and a nine-line comment. Removing the redundant condition also removes `report` from the effect's +dep array, making "clearing report cannot re-fire this" structural. Look for this shape in any fix that +_added_ a guard without deleting what it guards against. + +### A total-object tier factory only forces a decision in the cases that spread it (PR #1988 review) + +Follow-up to the `Pick<>` entry above: getting the factory right is necessary but not sufficient. A named +tier type + annotated total-literal factory produces the compile error, but **any reducer case that +hand-lists that tier's fields instead of spreading the factory keeps the hole** — the new field silently +defaults to _kept_ there. PR #1988 had `freshContentTier()` correct and then bypassed it in `SELECT_SOURCE` +and `DISCARD_EDITS`, the two cases that clear content state, because each needed one field _preserved_ +(`aiError`). Reviewing a tier-factory design: grep every case for the tier's field names appearing as +literal keys; each hit is an unenforced case. The fix is always the same shape — spread the factory, then +name the exception on the next line (`...freshContentTier(), aiError: state.aiError`), which is +behaviour-identical and makes the KEEP the thing that is written down rather than the CLEAR. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index e9c12ded1..a450b52fd 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -688,7 +688,7 @@ the empty-fallback makes it incapable of emptying a valid field), prompt rule 4 amended not deleted with the submodule ref bumped on-branch (AC 4.1). 193/193 + 187/187 green locally. Non-blocking: `futureDateStr` now triplicated (`timeline.test.ts:151` + 2 copies) while -`server/src/test-helpers/` exists — and the two new copies dropped the JSDoc that carries the *reason* +`server/src/test-helpers/` exists — and the two new copies dropped the JSDoc that carries the _reason_ (CPM today-floor on `not_started`), i.e. exactly the knowledge #1913 was filed to preserve. ### Round 2 (`857fcedd`) — APPROVED @@ -705,3 +705,30 @@ Accepted residuals, recorded so they are not rediscovered as bugs: `` stripped (INFO-1, AC 2.4's `Beträge < 500 EUR` safe via the space-after-`<` guard); a genuine German date list of ≥2 lines (`15. Mai: …\n16. Mai: …`) still loses its numbers, which is arguably correct (INFO-2). `futureDateStr` extraction to `server/src/test-helpers/dates.ts` deferred as a follow-up. + +## PR #1988 (#1967 dead `attachmentsNote` override + #1947 `ReportWizardPage` → `useReducer`) + +### Round 1 (`b503e496`) — CHANGES_REQUIRED, one HIGH + +The tier design I specified in the #1947 action-set review landed correctly (named tier interfaces, +annotated total-literal factories, opaque nullable request tokens, `isGeneratingAi` derived not stored, +the Step-2 fan-out race tokenized, `deepLinkAppliedRef` holding the applied id with `report` out of the +dep array). H1 was that `freshContentTier()` was **bypassed** in `SELECT_SOURCE` and `DISCARD_EDITS` — +the two cascades — because each needed `aiError` preserved, so a future 5th `ContentTier` field would +silently default to _kept_ in exactly the handler that produced #1943 and M2. See the recurring-patterns +entry; the fix shape is spread-the-factory-then-name-the-exception. + +Method note: for a behaviour-preserving refactor, **green CI is necessary but not the review**. What +settled AC3 here was walking each silently-changed semantic and proving it unreachable — `GO_TO_STEP` +now bumping `maxReachedStep` at every call site (no-op: step-1 Next only renders once `useCase` is set, +and `WizardStepper` gates clickability on `maxReachedStep`), the new `Math.min` step clamps (use case is +only selectable at step 1, source at step 2), `freshContentTier()` in `SELECT_USE_CASE` (no-op because +`isDirty` covers all three content fields, and the dirty path dispatches `DISCARD_EDITS` first). + +### Round 2 (`01d8ff12`) — APPROVED + +Both cascades now spread the factory and override `aiError` back; the M-I test passes unmodified, which is +the pin that matters. 57 tests, 100% on all four metrics. Also confirmed the one-file Prettier fix did not +drag repo-wide drift with it. Carried forward non-blocking: whole-`wizardState` in a `useCallback` dep +array, `REPORT_REFRESHED` as the last untokenized async write, `reportStatus` duplicating the page-local +`PageStatus` union, and a comma-operator exhaustiveness guard that invites deletion. diff --git a/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md b/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md new file mode 100644 index 000000000..0246e6616 --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/story-1947-wizardreducer.md @@ -0,0 +1,21 @@ +--- +name: story-1947-wizardreducer +description: wizardReducer.ts pure unit test patterns — tier factories, staleness guards, M-I/M-J regression tests, exhaustiveness guard +metadata: + type: project +--- + +New pure unit test file `client/src/pages/ReportWizardPage/wizardReducer.test.ts` (57 tests, 100% coverage on wizardReducer.ts). + +**Why:** Story #1947 extracted inline reducer logic from ReportWizardPage into a pure module; QA owns the unit tests. + +**Key patterns used:** + +- `makeState(overrides?)` helper spreads `createInitialWizardState(null)` — keeps tests minimal and focused. +- Staleness guards tested with `toBe(state)` (same object reference) — proves the reducer short-circuits, not just "returns equivalent state". +- M-I regression (SELECT_SOURCE must NOT clear `aiError`): tested with a comment that the guard must fail if someone adds `aiError: ''` to that case. +- M-J regression (REPORT_REFRESHED no-op when report=null): `toBe(state)` reference check. +- Exhaustiveness guard (line 282, `action satisfies never`): cast to `any` to hit default branch. +- `nextRequestId` exported function: test that two consecutive calls produce strings where the second is +1 of the first (counter monotonically increases). + +**How to apply:** For future pure reducer modules: use the same `makeState()` pattern, test staleness guards with `toBe`, test exhaustiveness with `any` cast. diff --git a/client/src/lib/reportContent/applyOverrides.test.ts b/client/src/lib/reportContent/applyOverrides.test.ts index 1ced2be53..837b63033 100644 --- a/client/src/lib/reportContent/applyOverrides.test.ts +++ b/client/src/lib/reportContent/applyOverrides.test.ts @@ -4,7 +4,7 @@ * applyOverrides is a pure function: given a baseline ReportContent and a flat * ReportContentOverrides map, it returns a NEW ReportContent with the recognized override keys * applied, without mutating the input. Recognized keys: coverLetter.{sender,recipient,reference, - * subject,body} and row..{usageText,attachmentsNote}. Unknown keys are silently + * subject,body,signature} and row..usageText. Unknown keys are silently * ignored. Overriding coverLetter.sender recomputes coverLetter.signature. */ import { describe, it, expect } from '@jest/globals'; @@ -291,20 +291,6 @@ describe('applyOverrides — row overrides', () => { expect(result.rows.find((r) => r.invoiceId === 'inv-b')!.usageText).toBe('B baseline'); }); - it('overrides attachmentsNote with a non-empty string', () => { - const row = makeRow({ attachmentsNote: '1 attachment: Invoice' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': 'Edited note' }); - expect(result.rows[0]!.attachmentsNote).toBe('Edited note'); - }); - - it('overriding attachmentsNote with an empty string coerces it to null', () => { - const row = makeRow({ attachmentsNote: '1 attachment: Invoice' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': '' }); - expect(result.rows[0]!.attachmentsNote).toBeNull(); - }); - it('overriding usageText with an empty string coerces it to an empty string (never null)', () => { const content = makeContent(); const result = applyOverrides(content, { 'row.inv-1.usageText': '' }); @@ -318,15 +304,23 @@ describe('applyOverrides — row overrides', () => { expect(result.rows[0]!.usageText).toBe(content.rows[0]!.usageText); }); - it('applies both usageText and attachmentsNote overrides for the same row together', () => { - const row = makeRow({ attachmentsNote: 'baseline note' }); - const content = makeContent({ rows: [row] }); - const result = applyOverrides(content, { - 'row.inv-1.usageText': 'Edited usage', - 'row.inv-1.attachmentsNote': 'Edited note', - }); - expect(result.rows[0]!.usageText).toBe('Edited usage'); - expect(result.rows[0]!.attachmentsNote).toBe('Edited note'); + it('silently ignores the row.attachmentsNote key (dead since #1959)', () => { + // Arrange: content with an invoice row where attachmentsNote is null (the default) + const content = makeContent(); + // Act: call applyOverrides with the attachmentsNote key — no `in`-check exists for it in + // applyOverrides.ts, so it cannot update any row field + const result = applyOverrides(content, { 'row.inv-1.attachmentsNote': 'some-override' }); + // Assert: attachmentsNote is still null — the key is silently ignored + expect(result.rows[0]!.attachmentsNote).toBeNull(); + }); + + it('still applies usageText override (positive control for remaining field coverage)', () => { + // Arrange: content with an invoice row + const content = makeContent(); + // Act: apply the usageText key — it IS in the `in`-check inside applyOverrides.ts + const result = applyOverrides(content, { 'row.inv-1.usageText': 'positive-control' }); + // Assert: the field was updated — proves the row-loop is still wired up correctly + expect(result.rows[0]!.usageText).toBe('positive-control'); }); }); diff --git a/client/src/lib/reportContent/applyOverrides.ts b/client/src/lib/reportContent/applyOverrides.ts index dfac50afe..9755c94c9 100644 --- a/client/src/lib/reportContent/applyOverrides.ts +++ b/client/src/lib/reportContent/applyOverrides.ts @@ -1,7 +1,7 @@ /** * Apply user overrides to baseline ReportContent. * Pure function: returns a new ReportContent without mutating the input. - * Recognized override keys: coverLetter.{sender,recipient,reference,subject,body,signature}, row..{usageText,attachmentsNote} + * Recognized override keys: coverLetter.{sender,recipient,reference,subject,body,signature}, row..usageText * Unknown keys are silently ignored. * When sender is overridden, signature is recomputed from it UNLESS signature has itself been * explicitly overridden — an explicit signature override always wins (AC 2.6). @@ -86,9 +86,6 @@ export function applyOverrides( if (rowKeys.usageText in overrides) { row.usageText = overrides[rowKeys.usageText] || ''; } - if (rowKeys.attachmentsNote in overrides) { - row.attachmentsNote = overrides[rowKeys.attachmentsNote] || null; - } } return result; diff --git a/client/src/lib/reportContent/overrideKeys.test.ts b/client/src/lib/reportContent/overrideKeys.test.ts index 5696bea4d..116612534 100644 --- a/client/src/lib/reportContent/overrideKeys.test.ts +++ b/client/src/lib/reportContent/overrideKeys.test.ts @@ -4,7 +4,7 @@ * `overrideKey` is a pure, side-effect-free builder for override map keys, decoupling * applyOverrides.ts and ReportContentEditor.tsx from manually-constructed string literals. * `overrideKey.coverLetter` is a fixed set of literal string constants; `overrideKey.row(id)` is a - * factory that interpolates a given invoiceId into two field-specific keys. + * factory that interpolates a given invoiceId into one field-specific key. */ import { describe, it, expect } from '@jest/globals'; import { overrideKey } from './overrideKeys.js'; @@ -41,10 +41,9 @@ describe('overrideKey.coverLetter — fixed literal keys', () => { }); describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { - it('interpolates a simple invoiceId into both the usageText and attachmentsNote keys', () => { + it('interpolates a simple invoiceId into the usageText key', () => { expect(overrideKey.row('inv-1')).toEqual({ usageText: 'row.inv-1.usageText', - attachmentsNote: 'row.inv-1.attachmentsNote', }); }); @@ -58,11 +57,10 @@ describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { it('correctly interpolates an invoiceId that itself contains a literal "." with no key ambiguity', () => { // A UUID-like or namespaced invoiceId containing dots must not be confused with the key's own - // "row." / ".usageText" / ".attachmentsNote" structural dot-separators — the whole id is used - // verbatim as the middle segment, however many dots it contains. + // "row." / ".usageText" structural dot-separators — the whole id is used verbatim as the + // middle segment, however many dots it contains. const keys = overrideKey.row('src.2026.inv-42'); expect(keys.usageText).toBe('row.src.2026.inv-42.usageText'); - expect(keys.attachmentsNote).toBe('row.src.2026.inv-42.attachmentsNote'); // Splitting on '.' yields more than 3 segments (proving the id's own dots survived verbatim, // rather than being collapsed/stripped), and the first/last segments are still the fixed @@ -77,8 +75,6 @@ describe('overrideKey.row(invoiceId) — interpolated per-row keys', () => { const keys = overrideKey.row('any-id-123'); expect(keys.usageText.startsWith('row.')).toBe(true); expect(keys.usageText.endsWith('.usageText')).toBe(true); - expect(keys.attachmentsNote.startsWith('row.')).toBe(true); - expect(keys.attachmentsNote.endsWith('.attachmentsNote')).toBe(true); }); it('returns a fresh object on each call (not a shared/mutated singleton)', () => { diff --git a/client/src/lib/reportContent/overrideKeys.ts b/client/src/lib/reportContent/overrideKeys.ts index f7a485337..f3e8010d7 100644 --- a/client/src/lib/reportContent/overrideKeys.ts +++ b/client/src/lib/reportContent/overrideKeys.ts @@ -14,6 +14,5 @@ export const overrideKey = { }, row: (invoiceId: string) => ({ usageText: `row.${invoiceId}.usageText`, - attachmentsNote: `row.${invoiceId}.attachmentsNote`, }), } as const; diff --git a/client/src/lib/reportContent/types.ts b/client/src/lib/reportContent/types.ts index ed69aee96..403adf479 100644 --- a/client/src/lib/reportContent/types.ts +++ b/client/src/lib/reportContent/types.ts @@ -19,10 +19,7 @@ export interface ReportContentRow { isRefund: boolean; refundNoteText: string; // shown only when isRefund usageText: string; // EDITABLE — key `row..usageText` - // READ-ONLY since #1959 moved it inline, with areaText, into the Usage cell's grey meta suffix: - // the editor renders both as static text and exposes no input for either. applyOverrides still - // honours `row..attachmentsNote`, but nothing can produce that key any more — it is - // unreachable from the UI. null = no docs, omitted entirely. + // READ-ONLY since #1959: rendered inline in the Usage cell's grey meta suffix. null = no attached documents. attachmentsNote: string | null; areaText: string | null; // read-only leaf area names, distinct comma-joined } diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx index 8950888ac..3bb2afe7d 100644 --- a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx +++ b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx @@ -1,15 +1,10 @@ -import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef, useReducer } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import type { - BudgetSource, - SourceReportType, - HouseholdSettings, - GenerateReportContentResponse, -} from '@cornerstone/shared'; +import type { BudgetSource, SourceReportType, HouseholdSettings } from '@cornerstone/shared'; import i18n from '../../i18n/index.js'; import { useAuth } from '../../contexts/AuthContext.js'; -import { useLocale, type ResolvedLocale } from '../../contexts/LocaleContext.js'; +import { useLocale } from '../../contexts/LocaleContext.js'; import { fetchBudgetSources } from '../../lib/budgetSourcesApi.js'; import { fetchHouseholdSettings } from '../../lib/settingsApi.js'; import { fetchConfig } from '../../lib/configApi.js'; @@ -26,14 +21,12 @@ import { applyOverrides, applyAiContent, type ReportContent, - type ReportContentOverrides, } from '../../lib/reportContent/index.js'; import { generateReportPdf, downloadPdf, createPreviewUrl, uploadToPaperless, - type SkippedDocument, } from '../../lib/reportPdf/index.js'; import { ApiClientError } from '../../lib/apiClient.js'; import { translateApiError } from '../../lib/errorTranslation.js'; @@ -53,6 +46,15 @@ import { Step1UseCase } from './Step1UseCase.js'; import { Step2Source } from './Step2Source.js'; import { Step4Settings } from './Step4Settings.js'; import { Step5Actions } from './Step5Actions.js'; +import { + wizardReducer, + createInitialWizardState, + nextRequestId, + hasManualEdits, + isDirty, + isGeneratingOnly, + isGeneratingAi, +} from './wizardReducer.js'; import sharedStyles from '../../styles/shared.module.css'; import styles from './ReportWizardPage.module.css'; @@ -66,20 +68,42 @@ export function ReportWizardPage() { const { resolvedLocale, currency } = useLocale(); const [searchParams] = useSearchParams(); - // Step navigation - const [currentStep, setCurrentStep] = useState(1); - const [maxReachedStep, setMaxReachedStep] = useState(1); + const sourceIdFromQuery = searchParams.get('sourceId'); + const [wizardState, dispatch] = useReducer( + wizardReducer, + sourceIdFromQuery, + createInitialWizardState, + ); + + const { + useCase, + sourceId, + step2Amounts, + step2Loading, + report, + reportStatus, + excludedInvoiceIds, + excludedLineIds, + overrides, + aiContent, + aiError, + reportLanguageOverride, + attachDocuments, + includeCoverLetter, + currentStep, + maxReachedStep, + skippedDocuments, + } = wizardState; + + const isGeneratingAiValue = isGeneratingAi(wizardState); + const isDirtyValue = isDirty(wizardState); // Focus management for step headings const stepHeadingsRef = useRef<(HTMLHeadingElement | null)[]>([]); // Report language selection (derived default: override takes precedence, falls back to resolvedLocale) - const [reportLanguageOverride, setReportLanguageOverride] = useState(null); const reportLanguage = reportLanguageOverride ?? resolvedLocale; - // Use case selection - const [useCase, setUseCase] = useState(null); - // Budget sources const [budgetSources, setBudgetSources] = useState([]); const [sourcesStatus, setSourcesStatus] = useState('loading'); @@ -87,66 +111,25 @@ export function ReportWizardPage() { // LLM configuration const [llmEnabled, setLlmEnabled] = useState(false); - // AI generation state - const [aiContent, setAiContent] = useState(null); - const [isGeneratingAi, setIsGeneratingAi] = useState(false); + // AI generation state (UI-only, not wizard state) const [aiElapsed, setAiElapsed] = useState(0); - const [aiError, setAiError] = useState(''); const [showAiOverwriteConfirm, setShowAiOverwriteConfirm] = useState(false); const pendingAiGenerationRef = useRef<(() => void) | null>(null); - // Step 2 amounts - const [step2Amounts, setStep2Amounts] = useState>(new Map()); - const [step2Loading, setStep2Loading] = useState(false); - - // Source selection - const sourceIdFromQuery = searchParams.get('sourceId'); - const [sourceId, setSourceId] = useState(sourceIdFromQuery); + // Source selection (derived) const selectedSource = useMemo( () => budgetSources.find((s) => s.id === sourceId) || null, [budgetSources, sourceId], ); - // Report data - const [report, setReport] = useState> | null>(null); - const [reportStatus, setReportStatus] = useState('loading'); - - // Invoice selection - const [excludedInvoiceIds, setExcludedInvoiceIds] = useState>(new Set()); - - // Line-level exclusions - const [excludedLineIds, setExcludedLineIds] = useState>(new Set()); - - // PDF generation & options - const [attachDocuments, setAttachDocuments] = useState(true); - const [includeCoverLetter, setIncludeCoverLetter] = useState(false); - - // Editable content overrides - const [overrides, setOverrides] = useState({}); - // Discard confirmation modal const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); const pendingChangeRef = useRef<(() => void) | null>(null); - // #1943: the ?sourceId= deep link auto-selects a source AT MOST ONCE per page load. Without - // this guard, clearing `report` as part of a use-case change re-satisfies this effect's - // `!report` condition and silently re-fires handleSourceChange with the ORIGINAL query-string - // source id — re-selecting a source and pushing maxReachedStep back to 3, undoing the very - // reset handleUseCaseChange performs (see #1943 AC8). The ref persists for the component's - // full lifetime and is never reset: sourceIdFromQuery is derived from the URL's search params - // once and this page never calls setSearchParams, so the deep-link source id is immutable for - // as long as this component instance is mounted. - const deepLinkAppliedRef = useRef(false); - - // #1943 (M1): tokens the report-fetch race between handleUseCaseChange and handleSourceChange. - // Neither fetch aborts its predecessor, so an out-of-order resolution — a use-case-A fetch - // that settles AFTER a later use-case-B fetch for the same source — would let the stale A - // report win the `setReport`/`setReportStatus` write, reaching step 3 with a report from the - // wrong use case even though the reset above already cleared it. Bumping this token wherever - // a fetch starts and checking it in every callback before writing state discards any response - // that isn't from the most recently started fetch, in either the success or error path. - const reportRequestRef = useRef(0); - const aiGenerationTokenRef = useRef(0); + // Upgraded from useRef(false) per #1947 M-D decision. Holds the sourceId that was applied + // by the deep-link effect, or null if the effect has not yet fired. The ref is the sole guard; + // '!report' is dropped from the condition below, removing report from the effect's deps. + const deepLinkAppliedRef = useRef(null); // PDF preview modal const [showPdfPreviewModal, setShowPdfPreviewModal] = useState(false); @@ -156,7 +139,6 @@ export function ReportWizardPage() { const [actionError, setActionError] = useState(''); const modalPreviewUrlRef = useRef(null); const [modalPreviewUrl, setModalPreviewUrl] = useState(null); - const [skippedDocuments, setSkippedDocuments] = useState([]); // Household settings const [household, setHousehold] = useState(null); @@ -200,17 +182,9 @@ export function ReportWizardPage() { // Guard for mutations: if overrides, aiContent, or an in-flight generation exist, show confirm modal; else apply change immediately const guardedUpdate = useCallback( (applyChange: () => void) => { - const hasEdits = Object.keys(overrides).length > 0 || aiContent !== null; - const isDirty = hasEdits || isGeneratingAi; - if (isDirty) { + if (isDirtyValue) { pendingChangeRef.current = () => { - setOverrides({}); - setAiContent(null); - if (isGeneratingAi) { - aiGenerationTokenRef.current += 1; - setIsGeneratingAi(false); - setAiError(''); - } + dispatch({ type: 'DISCARD_EDITS' }); applyChange(); }; setShowDiscardConfirm(true); @@ -218,47 +192,29 @@ export function ReportWizardPage() { applyChange(); } }, - [overrides, aiContent, isGeneratingAi], + [isDirtyValue], ); // Handle use case selection const handleUseCaseChange = useCallback( (uc: SourceReportType) => { guardedUpdate(() => { - setUseCase(uc); - setMaxReachedStep(2); - setStep2Amounts(new Map()); - setStep2Loading(true); - - // #1943: a use-case change invalidates any report fetched under the previous use - // case (and the source-gated Step 2 Next control, which only checks `sourceId`). - // Clear both so the wizard can't carry a stale report into a later step. - // #1943 (M1): also bump the request token so an in-flight fetch from the previous - // use case can never win the race against a report fetched after this reset. - reportRequestRef.current += 1; - setReport(null); - setReportStatus('loading'); - setSourceId(null); - setExcludedInvoiceIds(new Set()); - setExcludedLineIds(new Set()); - setSkippedDocuments([]); - setAiError(''); - - // Fetch amounts for all sources in parallel - Promise.all( + const step2RequestId = nextRequestId(); + dispatch({ type: 'SELECT_USE_CASE', payload: { useCase: uc, step2RequestId } }); + + void Promise.all( budgetSources.map((source) => getSourceReport(uc, source.id) .then((r) => ({ sourceId: source.id, amount: r.totalAmount })) .catch(() => ({ sourceId: source.id, amount: 0 })), ), - ) - .then((results) => { - const map = new Map(results.map((r) => [r.sourceId, r.amount])); - setStep2Amounts(map); - }) - .finally(() => { - setStep2Loading(false); + ).then((results) => { + const amounts = new Map(results.map((r) => [r.sourceId, r.amount])); + dispatch({ + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: step2RequestId, amounts }, }); + }); }); }, [budgetSources, guardedUpdate], @@ -268,31 +224,17 @@ export function ReportWizardPage() { const handleSourceChange = useCallback( (sid: string) => { guardedUpdate(() => { - setSourceId(sid); - setExcludedInvoiceIds(new Set()); - setExcludedLineIds(new Set()); - setSkippedDocuments([]); - setMaxReachedStep(3); - setReportStatus('loading'); - - // #1943 (M1): bump the token before starting this fetch so it can only ever be the - // authoritative response for its own request generation — any earlier fetch (whether - // started under this use case or a previous one) is discarded below on resolution. - const requestId = ++reportRequestRef.current; + const requestId = nextRequestId(); + dispatch({ type: 'SELECT_SOURCE', payload: { sourceId: sid, requestId } }); if (useCase) { - getSourceReport(useCase, sid) + void getSourceReport(useCase, sid) .then((r) => { - if (reportRequestRef.current !== requestId) return; - setReport(r); - // Auto-enable cover letter based on source - setIncludeCoverLetter(Boolean(r.source.contactAddress || r.source.reference)); - setReportStatus('ready'); + dispatch({ type: 'REPORT_LOADED', payload: { requestId, report: r } }); }) .catch((err) => { - if (reportRequestRef.current !== requestId) return; console.error(err); - setReportStatus('error'); + dispatch({ type: 'REPORT_ERROR', payload: { requestId } }); }); } }); @@ -302,11 +244,11 @@ export function ReportWizardPage() { // Handle ?sourceId= query parameter deep link useEffect(() => { - if (useCase && sourceIdFromQuery && !report && !deepLinkAppliedRef.current) { - deepLinkAppliedRef.current = true; + if (useCase && sourceIdFromQuery && deepLinkAppliedRef.current !== sourceIdFromQuery) { + deepLinkAppliedRef.current = sourceIdFromQuery; handleSourceChange(sourceIdFromQuery); } - }, [useCase, sourceIdFromQuery, report, handleSourceChange]); + }, [useCase, sourceIdFromQuery, handleSourceChange]); // Report-language-specific translation and formatters const reportT = useMemo(() => i18n.getFixedT(reportLanguage, 'budget'), [reportLanguage]); @@ -376,7 +318,7 @@ export function ReportWizardPage() { reportT, ); - setSkippedDocuments(result.skippedDocuments); + dispatch({ type: 'PDF_GENERATED', payload: { skippedDocuments: result.skippedDocuments } }); return result; } catch (err) { console.error(err); @@ -541,15 +483,7 @@ export function ReportWizardPage() { if (useCase && sourceId) { try { const updated = await getSourceReport(useCase, sourceId); - setReport(updated); - // Reset excluded to only include still-present invoices - const stillPresent = new Set(); - for (const id of excludedInvoiceIds) { - if (updated.invoices.some((inv) => inv.invoiceId === id)) { - stillPresent.add(id); - } - } - setExcludedInvoiceIds(stillPresent); + dispatch({ type: 'REPORT_REFRESHED', payload: { report: updated } }); } catch { // Ignore refetch errors } @@ -568,17 +502,15 @@ export function ReportWizardPage() { // AI elapsed timer effect useEffect(() => { - if (!isGeneratingAi) { - setAiElapsed(0); - return; - } - + if (!isGeneratingAiValue) return; const id = setInterval(() => { setAiElapsed((n) => n + 1); }, 1000); - - return () => clearInterval(id); - }, [isGeneratingAi]); + return () => { + clearInterval(id); + setAiElapsed(0); + }; + }, [isGeneratingAiValue]); // Cleanup on unmount useEffect(() => { @@ -613,14 +545,12 @@ export function ReportWizardPage() { ); if (includedInvoiceIds.length === 0) { - setAiError(tErrors('EMPTY_SELECTION')); + dispatch({ type: 'AI_GENERATION_BLOCKED', payload: { error: tErrors('EMPTY_SELECTION') } }); return; } - // #1946: Capture token BEFORE setting isGeneratingAi - const token = ++aiGenerationTokenRef.current; - setIsGeneratingAi(true); - setAiError(''); + const requestId = nextRequestId(); + dispatch({ type: 'AI_GENERATION_STARTED', payload: { requestId } }); try { const result = await generateReportContent({ @@ -630,39 +560,28 @@ export function ReportWizardPage() { includedInvoiceIds, excludedLineIds: Array.from(excludedLineIds), }); - - // Token mismatch: user discarded this generation while in flight - if (aiGenerationTokenRef.current !== token) return; - - setAiContent(result); - setOverrides({}); + dispatch({ type: 'AI_GENERATION_COMPLETE', payload: { requestId, result } }); } catch (err) { - // Token mismatch: do not surface error for discarded generation - if (aiGenerationTokenRef.current !== token) return; - + let errorMessage: string; if (err instanceof ApiClientError) { - setAiError(translateApiError(err.error.code, tErrors)); + errorMessage = translateApiError(err.error.code, tErrors); } else { - setAiError(t('sourceReports.editable.aiGenerationFailed')); - } - } finally { - if (aiGenerationTokenRef.current === token) { - setIsGeneratingAi(false); + errorMessage = t('sourceReports.editable.aiGenerationFailed'); } + dispatch({ type: 'AI_GENERATION_ERROR', payload: { requestId, error: errorMessage } }); } }, [report, useCase, excludedLineIds, excludedInvoiceIds, sourceId, reportLanguage, t, tErrors]); // Handle generate with AI button click const handleGenerateWithAiClick = useCallback(() => { - const isDirty = Object.keys(overrides).length > 0; - - if (isDirty) { + const dirty = hasManualEdits(wizardState); + if (dirty) { pendingAiGenerationRef.current = runAiGeneration; setShowAiOverwriteConfirm(true); } else { void runAiGeneration(); } - }, [overrides, runAiGeneration]); + }, [wizardState, runAiGeneration]); const steps: WizardStep[] = [ { id: 'use-case', label: t('sourceReports.stepper.useCase') }, @@ -682,7 +601,7 @@ export function ReportWizardPage() { steps={steps} currentStep={currentStep} maxReachedStep={maxReachedStep} - onStepClick={(step) => setCurrentStep(step)} + onStepClick={(step) => dispatch({ type: 'GO_TO_STEP', payload: { step } })} ariaLabel={t('sourceReports.stepperAriaLabel')} mobileStepLabel={(current, total) => t('sourceReports.mobileStepLabel', { current, total })} /> @@ -705,7 +624,7 @@ export function ReportWizardPage() { @@ -736,14 +655,14 @@ export function ReportWizardPage() { @@ -921,10 +821,10 @@ export function ReportWizardPage() { type="button" className={sharedStyles.btnSecondary} onClick={handleGenerateWithAiClick} - disabled={isGeneratingAi} + disabled={isGeneratingAiValue} aria-describedby="enhanceWithAiDescription" > - {isGeneratingAi && ( + {isGeneratingAiValue && ( @@ -935,7 +835,7 @@ export function ReportWizardPage() { {t('sourceReports.editable.enhanceWithAiDescription')} - {isGeneratingAi && ( + {isGeneratingAiValue && (

    {t('sourceReports.editable.generating', { seconds: aiElapsed })}

    @@ -943,7 +843,7 @@ export function ReportWizardPage() { {aiError && } - {aiContent && !isGeneratingAi && ( + {aiContent && !isGeneratingAiValue && (

    {t('sourceReports.editable.aiGeneratedNote')}

    @@ -954,16 +854,10 @@ export function ReportWizardPage() { { - setOverrides((prev) => ({ ...prev, [key]: value })); - }} - onFieldReset={(key) => { - setOverrides((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); - }} + onFieldChange={(key, value) => + dispatch({ type: 'SET_OVERRIDE', payload: { key, value } }) + } + onFieldReset={(key) => dispatch({ type: 'RESET_OVERRIDE', payload: { key } })} t={t} /> @@ -1004,7 +898,7 @@ export function ReportWizardPage() { @@ -1017,7 +911,7 @@ export function ReportWizardPage() { {showDiscardConfirm && (

    - {isGeneratingAi && Object.keys(overrides).length === 0 && aiContent === null + {isGeneratingOnly(wizardState) ? t('sourceReports.editable.discardConfirmBodyGenerating') : t('sourceReports.editable.discardConfirmBody')}

    diff --git a/client/src/pages/ReportWizardPage/wizardReducer.test.ts b/client/src/pages/ReportWizardPage/wizardReducer.test.ts new file mode 100644 index 000000000..31bb62a25 --- /dev/null +++ b/client/src/pages/ReportWizardPage/wizardReducer.test.ts @@ -0,0 +1,893 @@ +/** + * Unit tests for wizardReducer.ts + * + * Pure unit tests — no React rendering, no jsdom, no module mocks. + * All tests operate on the reducer, factories, and selectors directly. + * + * Coverage: createInitialWizardState, wizardReducer (all 20 action types), + * isGeneratingAi, hasManualEdits, isDirty, isGeneratingOnly. + * + * Story #1947 / Bug #1943 regression tests are in Group 17. + */ +import { describe, it, expect } from '@jest/globals'; +import type { SourceReportResponse, GenerateReportContentResponse } from '@cornerstone/shared'; +import type { WizardState } from './wizardReducer.js'; +import { + createInitialWizardState, + wizardReducer, + nextRequestId, + isGeneratingAi, + hasManualEdits, + isDirty, + isGeneratingOnly, +} from './wizardReducer.js'; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeSourceSummary( + overrides: Partial = {}, +): SourceReportResponse['source'] { + return { + id: 'src-1', + name: 'Home Loan', + sourceType: 'bank_loan', + reference: null, + contactAddress: null, + ...overrides, + }; +} + +function makeInvoice(id: string): SourceReportResponse['invoices'][0] { + return { + invoiceId: id, + vendorId: 'vend-1', + vendorName: 'ACME', + invoiceNumber: `INV-${id}`, + date: '2026-01-10', + status: 'pending', + invoiceAmount: 1000, + allocatedAmount: 1000, + lineKind: 'invoice', + isSplit: false, + documents: [], + budgetLines: [ + { + id: `bl-${id}`, + description: 'Usage text', + allocatedPortion: 0, + linkedItem: null, + }, + ], + deposits: [], + }; +} + +function makeReport( + sourceId: string, + invoiceIds: string[] = ['inv-1'], + sourceOverrides: Partial = {}, +): SourceReportResponse { + return { + type: 'claim', + source: makeSourceSummary({ id: sourceId, ...sourceOverrides }), + invoices: invoiceIds.map((id) => makeInvoice(id)), + totalAmount: 1000, + unallocatedInvoices: [], + generatedAt: '2026-01-15T00:00:00.000Z', + }; +} + +function makeAiResult(): GenerateReportContentResponse { + return { + letterSubject: 'AI subject', + letterBody: 'AI body', + descriptions: { 'inv-1': 'AI description' }, + }; +} + +/** Build a WizardState by starting from createInitialWizardState(null) and spreading overrides. */ +function makeState(overrides: Partial = {}): WizardState { + return { ...createInitialWizardState(null), ...overrides }; +} + +// ─── nextRequestId ──────────────────────────────────────────────────────────── + +describe('nextRequestId', () => { + it('returns a string and increments on each call', () => { + const id1 = nextRequestId(); + const id2 = nextRequestId(); + expect(typeof id1).toBe('string'); + expect(id1.length).toBeGreaterThan(0); + expect(Number(id2)).toBe(Number(id1) + 1); + }); +}); + +// ─── Group 1: createInitialWizardState ──────────────────────────────────────── + +describe('createInitialWizardState', () => { + it('with null sourceId: sets sourceId null and all tier defaults', () => { + const state = createInitialWizardState(null); + expect(state.sourceId).toBeNull(); + expect(state.useCase).toBeNull(); + expect(state.currentStep).toBe(1); + expect(state.maxReachedStep).toBe(1); + expect(state.step2Loading).toBe(false); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.overrides).toEqual({}); + expect(state.aiRequestId).toBeNull(); + }); + + it("with 'src-42' sourceId: sets sourceId and all other fields match defaults", () => { + const state = createInitialWizardState('src-42'); + expect(state.sourceId).toBe('src-42'); + expect(state.useCase).toBeNull(); + expect(state.currentStep).toBe(1); + expect(state.maxReachedStep).toBe(1); + expect(state.step2Loading).toBe(false); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.overrides).toEqual({}); + expect(state.aiRequestId).toBeNull(); + }); +}); + +// ─── Group 2: Tier factories (via createInitialWizardState) ────────────────── + +describe('Tier factories (via createInitialWizardState)', () => { + it('freshReportTier shape: report=null, reportStatus=loading, reportRequestId=null, empty sets, []', () => { + const state = createInitialWizardState(null); + expect(state.report).toBeNull(); + expect(state.reportStatus).toBe('loading'); + expect(state.reportRequestId).toBeNull(); + expect(state.excludedInvoiceIds).toBeInstanceOf(Set); + expect(state.excludedInvoiceIds.size).toBe(0); + expect(state.excludedLineIds).toBeInstanceOf(Set); + expect(state.excludedLineIds.size).toBe(0); + expect(state.skippedDocuments).toEqual([]); + }); + + it('freshContentTier shape: aiContent=null, aiRequestId=null, aiError="", overrides={}', () => { + const state = createInitialWizardState(null); + expect(state.aiContent).toBeNull(); + expect(state.aiRequestId).toBeNull(); + expect(state.aiError).toBe(''); + expect(state.overrides).toEqual({}); + }); +}); + +// ─── Group 3: SELECT_USE_CASE ───────────────────────────────────────────────── + +describe('SELECT_USE_CASE', () => { + it('resets ReportTier to fresh values', () => { + const state = makeState({ + report: makeReport('src-1'), + reportStatus: 'ready', + reportRequestId: 'req-old', + excludedInvoiceIds: new Set(['inv-1']), + excludedLineIds: new Set(['bl-1']), + skippedDocuments: [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed', + vendorName: 'ACME', + invoiceNumber: 'INV-001', + }, + ], + }); + + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + + expect(next.report).toBeNull(); + expect(next.reportStatus).toBe('loading'); + expect(next.reportRequestId).toBeNull(); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.excludedLineIds.size).toBe(0); + expect(next.skippedDocuments).toEqual([]); + }); + + it('resets SelectionTier: sourceId=null, useCase=action.payload.useCase', () => { + const state = makeState({ sourceId: 'src-1', useCase: 'claim' }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + expect(next.sourceId).toBeNull(); + expect(next.useCase).toBe('budget-overview'); + }); + + it('sets step2Loading=true and step2RequestId', () => { + const state = makeState(); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-42' }, + }); + expect(next.step2Loading).toBe(true); + expect(next.step2RequestId).toBe('req-42'); + }); + + it('sets maxReachedStep=2; currentStep=min(prev,2)', () => { + const stateAt1 = makeState({ currentStep: 1, maxReachedStep: 1 }); + const next1 = wizardReducer(stateAt1, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next1.maxReachedStep).toBe(2); + expect(next1.currentStep).toBe(1); // stayed at 1 + + const stateAt4 = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next4 = wizardReducer(stateAt4, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-2' }, + }); + expect(next4.maxReachedStep).toBe(2); + expect(next4.currentStep).toBe(2); // clamped to 2 + }); + + it('clears ContentTier: overrides={}, aiContent=null, aiRequestId=null, aiError=""', () => { + const state = makeState({ + overrides: { 'row.inv-1.usageText': 'edited' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-req-1', + aiError: 'some error', + }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe(''); + }); + + it('preserves SettingsTier fields', () => { + const state = makeState({ + attachDocuments: false, + includeCoverLetter: true, + reportLanguageOverride: 'de', + }); + const next = wizardReducer(state, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-1' }, + }); + expect(next.attachDocuments).toBe(false); + expect(next.includeCoverLetter).toBe(true); + expect(next.reportLanguageOverride).toBe('de'); + }); +}); + +// ─── Group 4: SELECT_SOURCE ─────────────────────────────────────────────────── + +describe('SELECT_SOURCE', () => { + it('sets sourceId, reportRequestId, reportStatus=loading', () => { + const state = makeState({ sourceId: null, reportStatus: 'error' }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-10' }, + }); + expect(next.sourceId).toBe('src-2'); + expect(next.reportRequestId).toBe('req-10'); + expect(next.reportStatus).toBe('loading'); + }); + + it('resets excludedInvoiceIds, excludedLineIds, skippedDocuments', () => { + const state = makeState({ + excludedInvoiceIds: new Set(['inv-1']), + excludedLineIds: new Set(['bl-1']), + skippedDocuments: [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed', + vendorName: 'ACME', + invoiceNumber: null, + }, + ], + }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.excludedLineIds.size).toBe(0); + expect(next.skippedDocuments).toEqual([]); + }); + + it('clears overrides, aiContent, aiRequestId', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-1', + }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + }); + + it('M-I regression: does NOT clear aiError — aiError is preserved after SELECT_SOURCE', () => { + const state = makeState({ aiError: 'some error' }); + const next = wizardReducer(state, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-2', requestId: 'req-1' }, + }); + // CRITICAL: this must fail if someone adds `aiError: ''` to the SELECT_SOURCE case + expect(next.aiError).toBe('some error'); + }); + + it('sets maxReachedStep=3; currentStep=min(prev,3)', () => { + const stateAt2 = makeState({ currentStep: 2, maxReachedStep: 2 }); + const next2 = wizardReducer(stateAt2, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-1' }, + }); + expect(next2.maxReachedStep).toBe(3); + expect(next2.currentStep).toBe(2); // stayed at 2 + + const stateAt4 = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next4 = wizardReducer(stateAt4, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-2' }, + }); + expect(next4.maxReachedStep).toBe(3); + expect(next4.currentStep).toBe(3); // clamped to 3 + }); +}); + +// ─── Group 5: STEP2_AMOUNTS_LOADED ─────────────────────────────────────────── + +describe('STEP2_AMOUNTS_LOADED', () => { + it('matching requestId: updates step2Amounts, clears step2Loading and step2RequestId', () => { + const state = makeState({ step2RequestId: 'req-1', step2Loading: true }); + const amounts = new Map([['src-1', 50000]]); + const next = wizardReducer(state, { + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: 'req-1', amounts }, + }); + expect(next.step2Amounts).toBe(amounts); + expect(next.step2Loading).toBe(false); + expect(next.step2RequestId).toBeNull(); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ step2RequestId: 'req-current', step2Loading: true }); + const next = wizardReducer(state, { + type: 'STEP2_AMOUNTS_LOADED', + payload: { requestId: 'req-stale', amounts: new Map() }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 6: REPORT_LOADED ─────────────────────────────────────────────────── + +describe('REPORT_LOADED', () => { + it('matching requestId, source with contactAddress: sets report, status=ready, includeCoverLetter=true', () => { + const report = makeReport('src-1', ['inv-1'], { contactAddress: '123 Main St' }); + const state = makeState({ reportRequestId: 'req-1', includeCoverLetter: false }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-1', report }, + }); + expect(next.report).toBe(report); + expect(next.reportStatus).toBe('ready'); + expect(next.reportRequestId).toBeNull(); + expect(next.includeCoverLetter).toBe(true); + }); + + it('matching requestId, source with no contactAddress/reference: includeCoverLetter=false', () => { + const report = makeReport('src-1', ['inv-1'], { contactAddress: null, reference: null }); + const state = makeState({ reportRequestId: 'req-1', includeCoverLetter: true }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-1', report }, + }); + expect(next.includeCoverLetter).toBe(false); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ reportRequestId: 'req-current' }); + const next = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-stale', report: makeReport('src-1') }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 7: REPORT_ERROR ──────────────────────────────────────────────────── + +describe('REPORT_ERROR', () => { + it('matching requestId: sets reportStatus=error, clears reportRequestId', () => { + const state = makeState({ reportRequestId: 'req-1', reportStatus: 'loading' }); + const next = wizardReducer(state, { + type: 'REPORT_ERROR', + payload: { requestId: 'req-1' }, + }); + expect(next.reportStatus).toBe('error'); + expect(next.reportRequestId).toBeNull(); + }); + + it('non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ reportRequestId: 'req-current' }); + const next = wizardReducer(state, { + type: 'REPORT_ERROR', + payload: { requestId: 'req-stale' }, + }); + expect(next).toBe(state); + }); +}); + +// ─── Group 8: REPORT_REFRESHED ──────────────────────────────────────────────── + +describe('REPORT_REFRESHED', () => { + it('when state.report is null: returns same state reference unchanged (M-J no-op)', () => { + const state = makeState({ report: null }); + const next = wizardReducer(state, { + type: 'REPORT_REFRESHED', + payload: { report: makeReport('src-1') }, + }); + expect(next).toBe(state); + }); + + it('when state.report is non-null: updates report and prunes excludedInvoiceIds', () => { + const oldReport = makeReport('src-1', ['inv-1', 'inv-2', 'inv-3']); + const newReport = makeReport('src-1', ['inv-1', 'inv-3']); // inv-2 removed + const state = makeState({ + report: oldReport, + excludedInvoiceIds: new Set(['inv-1', 'inv-2']), // inv-2 no longer valid + }); + const next = wizardReducer(state, { + type: 'REPORT_REFRESHED', + payload: { report: newReport }, + }); + expect(next.report).toBe(newReport); + // inv-1 still valid → kept; inv-2 gone → pruned + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-2')).toBe(false); + expect(next.excludedInvoiceIds.has('inv-3')).toBe(false); // was never excluded + }); +}); + +// ─── Group 9: TOGGLE_INVOICE / TOGGLE_ALL_INVOICES / TOGGLE_LINE ───────────── + +describe('TOGGLE_INVOICE / TOGGLE_ALL_INVOICES / TOGGLE_LINE', () => { + it('TOGGLE_INVOICE excluded=true: adds invoiceId to excludedInvoiceIds', () => { + const state = makeState({ excludedInvoiceIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_INVOICE', + payload: { invoiceId: 'inv-1', excluded: true }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + }); + + it('TOGGLE_INVOICE excluded=false: removes invoiceId from excludedInvoiceIds', () => { + const state = makeState({ excludedInvoiceIds: new Set(['inv-1']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_INVOICE', + payload: { invoiceId: 'inv-1', excluded: false }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(false); + }); + + it('TOGGLE_ALL_INVOICES excludeAll=true: excludedInvoiceIds contains all invoice ids', () => { + const report = makeReport('src-1', ['inv-1', 'inv-2', 'inv-3']); + const state = makeState({ report, excludedInvoiceIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: true }, + }); + expect(next.excludedInvoiceIds.has('inv-1')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-2')).toBe(true); + expect(next.excludedInvoiceIds.has('inv-3')).toBe(true); + expect(next.excludedInvoiceIds.size).toBe(3); + }); + + it('TOGGLE_ALL_INVOICES excludeAll=false: excludedInvoiceIds is empty', () => { + const report = makeReport('src-1', ['inv-1', 'inv-2']); + const state = makeState({ report, excludedInvoiceIds: new Set(['inv-1', 'inv-2']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: false }, + }); + expect(next.excludedInvoiceIds.size).toBe(0); + }); + + it('TOGGLE_ALL_INVOICES when report is null: returns same state reference unchanged', () => { + const state = makeState({ report: null }); + const next = wizardReducer(state, { + type: 'TOGGLE_ALL_INVOICES', + payload: { excludeAll: true }, + }); + expect(next).toBe(state); + }); + + it('TOGGLE_LINE excluded=true: adds lineId to excludedLineIds', () => { + const state = makeState({ excludedLineIds: new Set() }); + const next = wizardReducer(state, { + type: 'TOGGLE_LINE', + payload: { lineId: 'bl-1', excluded: true }, + }); + expect(next.excludedLineIds.has('bl-1')).toBe(true); + }); + + it('TOGGLE_LINE excluded=false: removes lineId from excludedLineIds', () => { + const state = makeState({ excludedLineIds: new Set(['bl-1']) }); + const next = wizardReducer(state, { + type: 'TOGGLE_LINE', + payload: { lineId: 'bl-1', excluded: false }, + }); + expect(next.excludedLineIds.has('bl-1')).toBe(false); + }); +}); + +// ─── Group 10: Settings actions ─────────────────────────────────────────────── + +describe('Settings actions', () => { + it("SET_REPORT_LANGUAGE: updates reportLanguageOverride to 'de'", () => { + const state = makeState({ reportLanguageOverride: null }); + const next = wizardReducer(state, { + type: 'SET_REPORT_LANGUAGE', + payload: { lang: 'de' }, + }); + expect(next.reportLanguageOverride).toBe('de'); + }); + + it('SET_ATTACH_DOCUMENTS: toggles attachDocuments false→true and true→false', () => { + const stateOff = makeState({ attachDocuments: false }); + const nextOn = wizardReducer(stateOff, { + type: 'SET_ATTACH_DOCUMENTS', + payload: { value: true }, + }); + expect(nextOn.attachDocuments).toBe(true); + + const nextOff = wizardReducer(nextOn, { + type: 'SET_ATTACH_DOCUMENTS', + payload: { value: false }, + }); + expect(nextOff.attachDocuments).toBe(false); + }); + + it('SET_INCLUDE_COVER_LETTER: toggles includeCoverLetter', () => { + const stateOff = makeState({ includeCoverLetter: false }); + const nextOn = wizardReducer(stateOff, { + type: 'SET_INCLUDE_COVER_LETTER', + payload: { value: true }, + }); + expect(nextOn.includeCoverLetter).toBe(true); + + const nextOff = wizardReducer(nextOn, { + type: 'SET_INCLUDE_COVER_LETTER', + payload: { value: false }, + }); + expect(nextOff.includeCoverLetter).toBe(false); + }); +}); + +// ─── Group 11: Override actions ─────────────────────────────────────────────── + +describe('Override actions', () => { + it('SET_OVERRIDE: adds key/value, preserves other overrides', () => { + const state = makeState({ overrides: { 'row.inv-1.usageText': 'existing' } }); + const next = wizardReducer(state, { + type: 'SET_OVERRIDE', + payload: { key: 'row.inv-2.usageText', value: 'new value' }, + }); + expect(next.overrides['row.inv-1.usageText']).toBe('existing'); + expect(next.overrides['row.inv-2.usageText']).toBe('new value'); + }); + + it('RESET_OVERRIDE: removes specific key, other overrides remain', () => { + const state = makeState({ + overrides: { + 'row.inv-1.usageText': 'keep', + 'row.inv-2.usageText': 'remove', + }, + }); + const next = wizardReducer(state, { + type: 'RESET_OVERRIDE', + payload: { key: 'row.inv-2.usageText' }, + }); + expect(next.overrides['row.inv-1.usageText']).toBe('keep'); + expect('row.inv-2.usageText' in next.overrides).toBe(false); + }); +}); + +// ─── Group 12: AI generation lifecycle ─────────────────────────────────────── + +describe('AI generation lifecycle', () => { + it('AI_GENERATION_STARTED: sets aiRequestId=requestId, clears aiError', () => { + const state = makeState({ aiRequestId: null, aiError: 'previous error' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_STARTED', + payload: { requestId: 'ai-req-1' }, + }); + expect(next.aiRequestId).toBe('ai-req-1'); + expect(next.aiError).toBe(''); + }); + + it('AI_GENERATION_COMPLETE matching requestId: sets aiContent, clears overrides and aiRequestId', () => { + const result = makeAiResult(); + const state = makeState({ + aiRequestId: 'ai-req-1', + aiContent: null, + overrides: { 'row.inv-1.usageText': 'edited' }, + }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'ai-req-1', result }, + }); + expect(next.aiContent).toBe(result); + expect(next.overrides).toEqual({}); + expect(next.aiRequestId).toBeNull(); + expect(isGeneratingAi(next)).toBe(false); + }); + + it('AI_GENERATION_COMPLETE non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-current' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'ai-req-stale', result: makeAiResult() }, + }); + expect(next).toBe(state); + }); + + it('AI_GENERATION_ERROR matching requestId: sets aiError, clears aiRequestId', () => { + const state = makeState({ aiRequestId: 'ai-req-1' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_ERROR', + payload: { requestId: 'ai-req-1', error: 'LLM timeout' }, + }); + expect(next.aiError).toBe('LLM timeout'); + expect(next.aiRequestId).toBeNull(); + }); + + it('AI_GENERATION_ERROR non-matching requestId: returns same state reference unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-current' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_ERROR', + payload: { requestId: 'ai-req-stale', error: 'some error' }, + }); + expect(next).toBe(state); + }); + + it('AI_GENERATION_BLOCKED: sets aiError, leaves aiRequestId unchanged', () => { + const state = makeState({ aiRequestId: 'ai-req-in-flight', aiError: '' }); + const next = wizardReducer(state, { + type: 'AI_GENERATION_BLOCKED', + payload: { error: 'content policy block' }, + }); + expect(next.aiError).toBe('content policy block'); + // aiRequestId is preserved even if it was non-null + expect(next.aiRequestId).toBe('ai-req-in-flight'); + }); +}); + +// ─── Group 13: DISCARD_EDITS ────────────────────────────────────────────────── + +describe('DISCARD_EDITS', () => { + it('when aiRequestId is non-null: clears overrides, aiContent, aiRequestId, AND clears aiError', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: 'ai-req-1', + aiError: 'some error', + }); + const next = wizardReducer(state, { type: 'DISCARD_EDITS' }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe(''); // cleared because aiRequestId was non-null + }); + + it('when aiRequestId is null: clears overrides, aiContent, aiRequestId, but PRESERVES aiError', () => { + const state = makeState({ + overrides: { key: 'val' }, + aiContent: makeAiResult(), + aiRequestId: null, + aiError: 'persistent error', + }); + const next = wizardReducer(state, { type: 'DISCARD_EDITS' }); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.aiError).toBe('persistent error'); // preserved because aiRequestId was null + }); +}); + +// ─── Group 14: GO_TO_STEP ───────────────────────────────────────────────────── + +describe('GO_TO_STEP', () => { + it('forward navigation (step > maxReachedStep): updates currentStep and maxReachedStep', () => { + const state = makeState({ currentStep: 2, maxReachedStep: 2 }); + const next = wizardReducer(state, { type: 'GO_TO_STEP', payload: { step: 4 } }); + expect(next.currentStep).toBe(4); + expect(next.maxReachedStep).toBe(4); + }); + + it('backward navigation (step < maxReachedStep): updates currentStep, maxReachedStep unchanged', () => { + const state = makeState({ currentStep: 4, maxReachedStep: 4 }); + const next = wizardReducer(state, { type: 'GO_TO_STEP', payload: { step: 2 } }); + expect(next.currentStep).toBe(2); + expect(next.maxReachedStep).toBe(4); // unchanged + }); +}); + +// ─── Group 15: PDF_GENERATED ────────────────────────────────────────────────── + +describe('PDF_GENERATED', () => { + it('updates skippedDocuments to the payload value', () => { + const state = makeState({ skippedDocuments: [] }); + const skipped = [ + { + invoiceId: 'inv-1', + documentId: 'doc-1', + reason: 'footnoteFetchFailed' as const, + vendorName: 'ACME', + invoiceNumber: 'INV-001', + }, + ]; + const next = wizardReducer(state, { + type: 'PDF_GENERATED', + payload: { skippedDocuments: skipped }, + }); + expect(next.skippedDocuments).toBe(skipped); + }); +}); + +// ─── Group 16: Selectors ────────────────────────────────────────────────────── + +describe('Selectors', () => { + it('isGeneratingAi: true when aiRequestId is non-null; false when null', () => { + expect(isGeneratingAi(makeState({ aiRequestId: 'req-1' }))).toBe(true); + expect(isGeneratingAi(makeState({ aiRequestId: null }))).toBe(false); + }); + + it('hasManualEdits: true when overrides has at least one key; false when empty', () => { + expect(hasManualEdits(makeState({ overrides: { key: 'val' } }))).toBe(true); + expect(hasManualEdits(makeState({ overrides: {} }))).toBe(false); + }); + + it('isDirty: true from overrides, aiContent, or generating; false when all clear', () => { + expect(isDirty(makeState({ overrides: { key: 'val' } }))).toBe(true); + expect(isDirty(makeState({ aiContent: makeAiResult() }))).toBe(true); + expect(isDirty(makeState({ aiRequestId: 'req-1' }))).toBe(true); + expect(isDirty(makeState({ overrides: {}, aiContent: null, aiRequestId: null }))).toBe(false); + }); + + it('isGeneratingOnly: true when generating and no manual edits and no aiContent', () => { + // Generating, no overrides, no aiContent → true + expect( + isGeneratingOnly(makeState({ aiRequestId: 'req-1', overrides: {}, aiContent: null })), + ).toBe(true); + + // Has manual edits → false + expect( + isGeneratingOnly( + makeState({ aiRequestId: 'req-1', overrides: { key: 'val' }, aiContent: null }), + ), + ).toBe(false); + + // Has aiContent → false + expect( + isGeneratingOnly( + makeState({ aiRequestId: 'req-1', overrides: {}, aiContent: makeAiResult() }), + ), + ).toBe(false); + + // Not generating → false + expect(isGeneratingOnly(makeState({ aiRequestId: null, overrides: {}, aiContent: null }))).toBe( + false, + ); + }); +}); + +// ─── Group 17: AC5 Regression tests ────────────────────────────────────────── + +describe('AC5 Regression tests', () => { + it('Test 52 — Bug #1943 shape: SELECT_USE_CASE cascade-resets all downstream state', () => { + const startState = makeState({ + useCase: 'claim', + sourceId: 'src-1', + report: makeReport('src-1', ['inv-1']), + excludedInvoiceIds: new Set(['inv-1']), + overrides: { 'row.inv-1.usageText': 'edited' }, + aiContent: makeAiResult(), + aiRequestId: null, + }); + + const next = wizardReducer(startState, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'budget-overview', step2RequestId: 'req-1' }, + }); + + expect(next.report).toBeNull(); + expect(next.sourceId).toBeNull(); + expect(next.excludedInvoiceIds.size).toBe(0); + expect(next.overrides).toEqual({}); + expect(next.aiContent).toBeNull(); + expect(next.aiRequestId).toBeNull(); + expect(next.maxReachedStep).toBe(2); + }); + + it('Test 53 — Bug #1943 AC8 shape: SELECT_USE_CASE clears sourceId; SELECT_SOURCE re-applies it', () => { + const stateWithSource = makeState({ sourceId: 'src-1', useCase: 'budget-overview' }); + + const afterUseCaseChange = wizardReducer(stateWithSource, { + type: 'SELECT_USE_CASE', + payload: { useCase: 'claim', step2RequestId: 'req-2' }, + }); + expect(afterUseCaseChange.sourceId).toBeNull(); + + const afterSourceSelect = wizardReducer(afterUseCaseChange, { + type: 'SELECT_SOURCE', + payload: { sourceId: 'src-1', requestId: 'req-3' }, + }); + expect(afterSourceSelect.sourceId).toBe('src-1'); + }); + + it('Test 54 — Bug M1 shape: REPORT_LOADED is a no-op for stale requests', () => { + const state = makeState({ reportRequestId: 'req-current', report: null }); + + const afterStale = wizardReducer(state, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-stale', report: makeReport('src-1') }, + }); + expect(afterStale.report).toBeNull(); + expect(afterStale.reportStatus).toBe('loading'); + + const freshReport = makeReport('src-1'); + const afterFresh = wizardReducer(afterStale, { + type: 'REPORT_LOADED', + payload: { requestId: 'req-current', report: freshReport }, + }); + expect(afterFresh.report).toBe(freshReport); + expect(afterFresh.reportStatus).toBe('ready'); + }); + + it('Test 55 — Bug M2 shape: AI_GENERATION_COMPLETE is a no-op for stale requests', () => { + // Start generation + const stateStarted = wizardReducer(makeState(), { + type: 'AI_GENERATION_STARTED', + payload: { requestId: 'req-current' }, + }); + expect(stateStarted.aiRequestId).toBe('req-current'); + + // Stale completion: no-op + const afterStale = wizardReducer(stateStarted, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'req-stale', result: makeAiResult() as GenerateReportContentResponse }, + }); + expect(afterStale.aiContent).toBeNull(); + expect(isGeneratingAi(afterStale)).toBe(true); + + // Fresh completion: applies + const freshResult = makeAiResult(); + const afterFresh = wizardReducer(afterStale, { + type: 'AI_GENERATION_COMPLETE', + payload: { requestId: 'req-current', result: freshResult }, + }); + expect(afterFresh.aiContent).toBe(freshResult); + expect(isGeneratingAi(afterFresh)).toBe(false); + }); +}); + +// ─── TypeScript exhaustiveness guard (default branch) ──────────────────────── + +describe('wizardReducer default/exhaustiveness guard', () => { + it('returns state unchanged for an unknown action type (runtime safety)', () => { + const state = makeState(); + // Cast to `any` to bypass the TypeScript discriminated union and hit the `default` branch. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const next = wizardReducer(state, { type: 'UNKNOWN_ACTION' } as any); + expect(next).toBe(state); + }); +}); diff --git a/client/src/pages/ReportWizardPage/wizardReducer.ts b/client/src/pages/ReportWizardPage/wizardReducer.ts new file mode 100644 index 000000000..33afdce99 --- /dev/null +++ b/client/src/pages/ReportWizardPage/wizardReducer.ts @@ -0,0 +1,297 @@ +import type { + SourceReportType, + SourceReportResponse, + GenerateReportContentResponse, +} from '@cornerstone/shared'; +import type { ResolvedLocale } from '../../contexts/LocaleContext.js'; +import type { ReportContentOverrides } from '../../lib/reportContent/index.js'; +import type { SkippedDocument } from '../../lib/reportPdf/index.js'; + +export interface SelectionTier { + useCase: SourceReportType | null; + sourceId: string | null; +} + +export interface SourcesTier { + step2Amounts: Map; + step2Loading: boolean; + step2RequestId: string | null; +} + +export interface ReportTier { + report: SourceReportResponse | null; + reportStatus: 'loading' | 'ready' | 'error'; + reportRequestId: string | null; + excludedInvoiceIds: Set; + excludedLineIds: Set; + skippedDocuments: SkippedDocument[]; +} + +export interface ContentTier { + overrides: ReportContentOverrides; + aiContent: GenerateReportContentResponse | null; + aiRequestId: string | null; + aiError: string; +} + +export interface SettingsTier { + reportLanguageOverride: ResolvedLocale | null; + attachDocuments: boolean; + includeCoverLetter: boolean; +} + +export interface NavTier { + currentStep: number; + maxReachedStep: number; +} + +export type WizardState = SelectionTier & + SourcesTier & + ReportTier & + ContentTier & + SettingsTier & + NavTier; + +/** + * Tier factories — each returns a complete, fresh object of its named tier type. + * Reducer cases build their next state by spreading the relevant factories plus + * explicit field writes, NEVER by ad-hoc spread of individually-cleared fields. + * Adding a field to a tier type is a compile error in its factory, which is AC4. + */ +function freshSelectionTier(): SelectionTier { + return { useCase: null, sourceId: null }; +} + +function freshSourcesTier(): SourcesTier { + return { step2Amounts: new Map(), step2Loading: false, step2RequestId: null }; +} + +function freshReportTier(): ReportTier { + return { + report: null, + reportStatus: 'loading', + reportRequestId: null, + excludedInvoiceIds: new Set(), + excludedLineIds: new Set(), + skippedDocuments: [], + }; +} + +function freshContentTier(): ContentTier { + return { overrides: {}, aiContent: null, aiRequestId: null, aiError: '' }; +} + +function freshSettingsTier(): SettingsTier { + return { reportLanguageOverride: null, attachDocuments: true, includeCoverLetter: false }; +} + +function freshNavTier(): NavTier { + return { currentStep: 1, maxReachedStep: 1 }; +} + +let _requestCounter = 0; +export function nextRequestId(): string { + return String(++_requestCounter); +} + +export type WizardAction = + | { type: 'SELECT_USE_CASE'; payload: { useCase: SourceReportType; step2RequestId: string } } + | { type: 'SELECT_SOURCE'; payload: { sourceId: string; requestId: string } } + | { type: 'STEP2_AMOUNTS_LOADED'; payload: { requestId: string; amounts: Map } } + | { type: 'REPORT_LOADED'; payload: { requestId: string; report: SourceReportResponse } } + | { type: 'REPORT_ERROR'; payload: { requestId: string } } + | { type: 'REPORT_REFRESHED'; payload: { report: SourceReportResponse } } + | { type: 'TOGGLE_INVOICE'; payload: { invoiceId: string; excluded: boolean } } + | { type: 'TOGGLE_ALL_INVOICES'; payload: { excludeAll: boolean } } + | { type: 'TOGGLE_LINE'; payload: { lineId: string; excluded: boolean } } + | { type: 'SET_REPORT_LANGUAGE'; payload: { lang: ResolvedLocale } } + | { type: 'SET_ATTACH_DOCUMENTS'; payload: { value: boolean } } + | { type: 'SET_INCLUDE_COVER_LETTER'; payload: { value: boolean } } + | { type: 'SET_OVERRIDE'; payload: { key: string; value: string } } + | { type: 'RESET_OVERRIDE'; payload: { key: string } } + | { type: 'AI_GENERATION_STARTED'; payload: { requestId: string } } + | { + type: 'AI_GENERATION_COMPLETE'; + payload: { requestId: string; result: GenerateReportContentResponse }; + } + | { type: 'AI_GENERATION_ERROR'; payload: { requestId: string; error: string } } + | { type: 'AI_GENERATION_BLOCKED'; payload: { error: string } } + | { type: 'DISCARD_EDITS' } + | { type: 'GO_TO_STEP'; payload: { step: number } } + | { type: 'PDF_GENERATED'; payload: { skippedDocuments: SkippedDocument[] } }; + +export function createInitialWizardState(sourceIdFromQuery: string | null): WizardState { + return { + ...freshSelectionTier(), + ...freshSourcesTier(), + ...freshReportTier(), + ...freshContentTier(), + ...freshSettingsTier(), + ...freshNavTier(), + sourceId: sourceIdFromQuery, + }; +} + +export function wizardReducer(state: WizardState, action: WizardAction): WizardState { + switch (action.type) { + case 'SELECT_USE_CASE': + return { + ...state, + ...freshSelectionTier(), + ...freshSourcesTier(), + ...freshReportTier(), + ...freshContentTier(), + useCase: action.payload.useCase, + step2RequestId: action.payload.step2RequestId, + step2Loading: true, + currentStep: Math.min(state.currentStep, 2), + maxReachedStep: 2, + }; + + case 'SELECT_SOURCE': + // M-I: preserve aiError by spreading freshContentTier() then overriding aiError back. + // Adding a future ContentTier field will be caught here at compile time. + return { + ...state, + ...freshReportTier(), + reportRequestId: action.payload.requestId, + ...freshContentTier(), + aiError: state.aiError, + sourceId: action.payload.sourceId, + currentStep: Math.min(state.currentStep, 3), + maxReachedStep: 3, + }; + + case 'STEP2_AMOUNTS_LOADED': + if (action.payload.requestId !== state.step2RequestId) return state; + return { + ...state, + step2Amounts: action.payload.amounts, + step2Loading: false, + step2RequestId: null, + }; + + case 'REPORT_LOADED': + if (action.payload.requestId !== state.reportRequestId) return state; + return { + ...state, + report: action.payload.report, + reportStatus: 'ready', + reportRequestId: null, + includeCoverLetter: Boolean( + action.payload.report.source.contactAddress || action.payload.report.source.reference, + ), + }; + + case 'REPORT_ERROR': + if (action.payload.requestId !== state.reportRequestId) return state; + return { ...state, reportStatus: 'error', reportRequestId: null }; + + case 'REPORT_REFRESHED': { + if (state.report === null) return state; + const validInvoiceIds = new Set(action.payload.report.invoices.map((inv) => inv.invoiceId)); + return { + ...state, + report: action.payload.report, + excludedInvoiceIds: new Set( + [...state.excludedInvoiceIds].filter((id) => validInvoiceIds.has(id)), + ), + }; + } + + case 'TOGGLE_INVOICE': { + const next = new Set(state.excludedInvoiceIds); + if (action.payload.excluded) { + next.add(action.payload.invoiceId); + } else { + next.delete(action.payload.invoiceId); + } + return { ...state, excludedInvoiceIds: next }; + } + + case 'TOGGLE_ALL_INVOICES': + if (!state.report) return state; + return { + ...state, + excludedInvoiceIds: action.payload.excludeAll + ? new Set(state.report.invoices.map((inv) => inv.invoiceId)) + : new Set(), + }; + + case 'TOGGLE_LINE': { + const next = new Set(state.excludedLineIds); + if (action.payload.excluded) { + next.add(action.payload.lineId); + } else { + next.delete(action.payload.lineId); + } + return { ...state, excludedLineIds: next }; + } + + case 'SET_REPORT_LANGUAGE': + return { ...state, reportLanguageOverride: action.payload.lang }; + case 'SET_ATTACH_DOCUMENTS': + return { ...state, attachDocuments: action.payload.value }; + case 'SET_INCLUDE_COVER_LETTER': + return { ...state, includeCoverLetter: action.payload.value }; + + case 'SET_OVERRIDE': + return { + ...state, + overrides: { ...state.overrides, [action.payload.key]: action.payload.value }, + }; + case 'RESET_OVERRIDE': { + const next = { ...state.overrides }; + delete next[action.payload.key]; + return { ...state, overrides: next }; + } + + case 'AI_GENERATION_STARTED': + return { ...state, aiRequestId: action.payload.requestId, aiError: '' }; + case 'AI_GENERATION_COMPLETE': + if (action.payload.requestId !== state.aiRequestId) return state; + return { ...state, aiContent: action.payload.result, overrides: {}, aiRequestId: null }; + case 'AI_GENERATION_ERROR': + if (action.payload.requestId !== state.aiRequestId) return state; + return { ...state, aiError: action.payload.error, aiRequestId: null }; + case 'AI_GENERATION_BLOCKED': + return { ...state, aiError: action.payload.error }; + + case 'DISCARD_EDITS': + // M-I: spread freshContentTier() for AC4 enforcement, then override aiError conditionally. + return { + ...state, + ...freshContentTier(), + aiError: state.aiRequestId !== null ? '' : state.aiError, + }; + + case 'GO_TO_STEP': + return { + ...state, + currentStep: action.payload.step, + maxReachedStep: Math.max(state.maxReachedStep, action.payload.step), + }; + + case 'PDF_GENERATED': + return { ...state, skippedDocuments: action.payload.skippedDocuments }; + + default: + return (action satisfies never, state); + } +} + +export function isGeneratingAi(state: WizardState): boolean { + return state.aiRequestId !== null; +} + +export function hasManualEdits(state: WizardState): boolean { + return Object.keys(state.overrides).length > 0; +} + +export function isDirty(state: WizardState): boolean { + return hasManualEdits(state) || state.aiContent !== null || isGeneratingAi(state); +} + +export function isGeneratingOnly(state: WizardState): boolean { + return isGeneratingAi(state) && !hasManualEdits(state) && state.aiContent === null; +} From a5aa1dd87299af861c4791eaae3526d1445d3450 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 16:26:51 +0200 Subject: [PATCH 11/42] feat(auth): make rate limits configurable via AUTH_RATE_LIMIT_MAX/WINDOW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW` env vars to configure the login endpoint rate limit (defaults: 20 requests / 15 minutes) - Invalid values (zero/negative max, zero-magnitude or malformed window) cause startup failure with a descriptive error — no silent fallback - Setup route remains hardcoded with a code comment explaining why; wiki docs updated Fixes #1970 Co-Authored-By: Claude backend-developer Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude product-architect Co-Authored-By: Claude product-owner Co-Authored-By: Claude qa-integration-tester --- .../agent-memory/product-architect/MEMORY.md | 10 +- .../product-architect/recurring-patterns.md | 66 ++++++++++ .claude/agent-memory/product-owner/MEMORY.md | 6 +- .../product-owner/auth-rate-limits-1970.md | 102 +++++++++++++++ .../product-owner/bank-report-wizard.md | 15 +++ .../product-owner/pr-review-patterns.md | 24 +++- CLAUDE.md | 2 + server/src/plugins/config.test.ts | 116 ++++++++++++++++++ server/src/plugins/config.ts | 30 +++++ server/src/plugins/rateLimitPlugin.test.ts | 85 +++++++++++++ server/src/routes/auth.ts | 13 +- server/src/services/backupService.test.ts | 2 + .../services/budgetExtraction/index.test.ts | 2 + .../src/services/draftCleanupService.test.ts | 2 + ...voiceAutoItemizeService.mergeLines.test.ts | 2 + .../invoiceAutoItemizeService.patch.test.ts | 2 + .../invoiceAutoItemizeService.test.ts | 2 + .../reportContentGenerationService.test.ts | 2 + wiki | 2 +- 19 files changed, 473 insertions(+), 12 deletions(-) create mode 100644 .claude/agent-memory/product-owner/auth-rate-limits-1970.md diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 3ce668168..8078fba36 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,7 +2,7 @@ ## Topic Files -- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952), `Pick<>` is not a forcing function + caller-supplied monotonic seq reintroduces the ref + cascade tables smuggle behaviour changes + neutralised-trigger-left-in-code (#1947), tier factory only forces the cases that spread it (#1988) +- [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952), `Pick<>` is not a forcing function + caller-supplied monotonic seq reintroduces the ref + cascade tables smuggle behaviour changes + neutralised-trigger-left-in-code (#1947), tier factory only forces the cases that spread it (#1988), regex mirroring a third-party grammar + `parseInt` trailing garbage + env vars documented in four places (#1970, PR #1989) - [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 @@ -76,4 +76,10 @@ is still undocumented in Schema.md. - esbuild SIGILL on emulated aarch64; Docker build fails behind the TLS firewall - 4GB RAM: Jest OOM mitigated with `--maxWorkers=2 --max-old-space-size=2048` - Stale worktrees under `.claude/worktrees/` cause jest-haste-map duplicate-package failures. - Work around with `npx jest --modulePathIgnorePatterns='/.claude/worktrees/'` + Work around with `npx jest --modulePathIgnorePatterns='/.claude/worktrees/'` — **but only from + the base checkout.** Inside a worktree that pattern matches the cwd itself, so jest reports + `0 files checked across 3 projects` / `Pattern: - 0 matches` and exits 1. That looks like a + missing/misnamed test file, not a config problem, and can be misread as "the tests don't exist". + When running from a worktree, drop the flag entirely. +- Confirm a run actually executed something: `Tests: N passed` — a `--maxWorkers=1 -t ` run that + matched nothing still exits 0 in some invocations, so a silent pass is not evidence. diff --git a/.claude/agent-memory/product-architect/recurring-patterns.md b/.claude/agent-memory/product-architect/recurring-patterns.md index 4f5de792b..3714c5152 100644 --- a/.claude/agent-memory/product-architect/recurring-patterns.md +++ b/.claude/agent-memory/product-architect/recurring-patterns.md @@ -786,3 +786,69 @@ and `DISCARD_EDITS`, the two cases that clear content state, because each needed literal keys; each hit is an unenforced case. The fix is always the same shape — spread the factory, then name the exception on the next line (`...freshContentTier(), aiError: state.aiError`), which is behaviour-identical and makes the KEEP the thing that is written down rather than the CLEAR. + +## Hand-rolled regex mirroring a third-party grammar accepts values the library rejects (PR #1989, #1970) + +Validating an env var with a regex that _approximates_ a library's parser produces configs that pass +startup validation and then blow up at request time. Concrete case: `AUTH_RATE_LIMIT_WINDOW` validated +by a bespoke duration regex, while `@fastify/rate-limit` v11 parses `timeWindow` strings with +**`@lukeed/ms`** (not the classic `ms` package). + +Two divergences found, both reaching the same failure: + +- **Zero magnitudes.** `@lukeed/ms` guards with `if (arr != null && (num = parseFloat(arr[1])))`, so + `'0s'`, `'0 minutes'`, `'0ms'`, `'0.0h'` all `parse()` to `undefined`. +- **Whitespace class.** `@lukeed/ms` uses ` *` between number and unit; `\s*` additionally accepts + `'15\tminutes'` / `'15\nminutes'`, which `parse()` rejects. + +Why it's fatal, not a fallback: `mergeParams()` in `@fastify/rate-limit/index.js:163-169` is an +`if / else if` chain — a _string_ takes branch 2, gets `undefined`, and never reaches the +`defaultTimeWindow` branch. At request time `await params.timeWindow(req, key)` throws, so **every** +request to the route returns `500 {"message":"params.timeWindow is not a function"}`. Verified: +`timeWindow: '0s'` on a route → 500. A zero window is the obvious way an operator tries to disable a +rate limit, so this defeats a "no value may disable the control" acceptance criterion. + +**Rule:** when a config value is handed verbatim to a third-party parser, validate it _with that +parser_ (declare the dep) rather than re-deriving its grammar. If a regex is unavoidable, also assert +the parsed result is defined and `> 0`, and check the library's actual source for which package it +uses — `@fastify/*` deps are not always the popular one. + +**Resolved in `47ee190` (APPROVED)** with regex + guard rather than delegating to the parser, and that +was accepted. Two transferable lessons: + +- **Direction of divergence is what matters, not divergence itself.** A hand-rolled regex that is + strictly _narrower_ than the library is fail-closed and fine: config rejects `1y`, `1wk`, `100msec`, + `.5s`, `-5m` at startup with an actionable message. Only the _wider_ direction (config accepts what + the parser chokes on) is a blocker. Don't demand exact grammar parity in review — demand that the + accept-set be a subset, then sanity-check that the excluded values are ones nobody wants. +- **Verify a grammar claim by brute force, not by reading.** Cross-checking "does anything pass my gate + that the library can't parse?" over all units × magnitudes × separator widths took one throwaway + script and turned an inspection argument into `config-accepts-but-lukeed-fails: NONE`. Import the + library's built file by relative path (`./node_modules//dist/index.mjs`) from a script placed in + the repo root — a script in `/tmp` cannot resolve the bare package name. +- Ordering detail worth preserving: the positive-magnitude guard must be an `else if` _after_ the + pattern test, so `parseFloat` only sees strings already known to start with `\d+`. Reversing them + reintroduces a `NaN` path. + +## `parseInt` config validation accepts trailing garbage repo-wide + +`loadConfig()` uses `parseInt(str, 10)` + `isNaN` for every numeric env var (`PORT`, +`SESSION_DURATION`, `PHOTO_MAX_FILE_SIZE_MB`, `LLM_MAX_TOKENS`, `BACKUP_RETENTION`, +`AUTH_RATE_LIMIT_MAX`). So `20abc` → `20`, `20.9` → `20`, `1e3` → `1`, despite error messages that say +"must be a positive integer". Any AC demanding "non-numeric value fails startup" is only partly met. +Don't request a one-variable fix in review — it creates local inconsistency; either accept the pattern +or propose a uniform `/^\d+$/` guard across `loadConfig()` as its own item. + +## Env vars are documented in four places, not one + +Adding an env var means: `CLAUDE.md` table, `wiki/Architecture.md` (topic-grouped tables — e.g. +"Authentication & Sessions" ~L393), `wiki/API-Contract.md` ("Environment Variables (Auth)" ~L107), and +`docs/src/getting-started/configuration.md` (**docs-writer-owned** — file a request, don't edit). +The first three belong in the implementing PR with the submodule ref bumped on the branch. PR #1989 +updated only `CLAUDE.md` at first review; `47ee190` added both wiki pages, leaving the docs-writer one +as a release-staging follow-up — that is the correct end state, so treat "3 of 4 + a flagged follow-up" +as the passing bar, not 4 of 4. + +Cheap way to find every location when adding a var: grep an _existing_ comparable var repo-wide +(`grep -rln SESSION_DURATION --include='*.md' --include='*.yml' .`) instead of guessing which files +need touching. It also surfaces the ADR pages that pin a default. diff --git a/.claude/agent-memory/product-owner/MEMORY.md b/.claude/agent-memory/product-owner/MEMORY.md index 7ebea615a..edf05067f 100644 --- a/.claude/agent-memory/product-owner/MEMORY.md +++ b/.claude/agent-memory/product-owner/MEMORY.md @@ -33,15 +33,15 @@ Full detail in [standalone-bugs-and-stories.md](standalone-bugs-and-stories.md) (budget/invoice) and [standalone-diary-bugs.md](standalone-diary-bugs.md) / [standalone-photo-stories.md](standalone-photo-stories.md). Growing budget/invoice cluster (20+) → propose new "Budget/Invoice UX Polish" epic next planning cycle. Auto-itemize is standalone (no parent epic): stories #1545-#1547 + bug fixes. -- **#1932 scope ruled by user 2026-08-02**: cover-letter body = **plain text with line breaks**, no markdown/WYSIWYG/new dep; bold+lists out of scope. Premise correction: **pdfmake already honours `\n`** (`TextBreaker.js` L30-34/53-58) so §1 became regression guards + the AI-prompt guard (AC 1.6). ACs 1.1-1.6 amended, 1.4 struck, 1.5 kept as negative constraint, **AC 2.6 added** (`applyOverrides` signature-from-sender recompute), paragraph spacing moved §1→§4.1. Blocked-by = only #1939 (PR #1948). Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1932 user scope ruling". **PR #1951 APPROVED round 1** (all 40 ACs, 3 MUST FIX); architect review filed 2 follow-ups 2026-08-02: **#1952** (tech-debt, Should Have, Todo — plain-prose guarantee is prompt-level only; ruled *strip, not reject* in the validator, §2 weighted on false-positive guards) and **#1953** (tech-debt, Could Have, Backlog, blocked-by #1932 — `letterSubject` false-shares `SUBHEADER_FONT_SIZE` with `headerFootprint()`; ruled the equality **coincidental**, independent literal not an alias, + records the `PDF_STYLES` split trigger in the file header). Architect's own `react/no-danger` follow-up **verified not filed** — prompted on the PR, not duplicated. Rulings in [bank-report-wizard.md](bank-report-wizard.md) §"#1951 architecture-review follow-ups". - Budget/invoice batches: #1369-#1373 (2026-04-28), #1389-#1390 (2026-04-29), #1401 (2026-05-10), #1421-#1425 (2026-05-15), #1439-#1441 (2026-05-17), #1553 (2026-05-22) - Auto-itemize: #1545/#1546/#1547 mini-epic (2026-05-21), #1600 (2026-05-26), **#1833 duplicate budget lines on commit retry (2026-07-07)** +- **Auth rate limits: #1970** — PR #1989 APPROVED round 3 (2026-08-04), M1 resolved (`5446b29a`), all 7 ACs met → **Done on merge**. Follow-ups #1990/#1991/#1992 stay open. Detail in [auth-rate-limits-1970.md](auth-rate-limits-1970.md) — incl. verifying "proves X reached the route" assertions **by local mutation + revert**, and checking a numeric-header probe is deterministic (which request in the window it observes). - Diary: #1426 critical photo data loss (2026-05-15) - Photo: #1723 lightbox picker UX (2026-06-16) - **DataTable: #1955** two-column toggle race silently hides 2nd column, all 6 DataTable pages (Should Have, S, Backlog, 2026-08-02). See [datatable-column-preference-race.md](datatable-column-preference-race.md) — records that **fast clicking is the SAFE case** (I judged this backwards; debounce `clearTimeout` coalesces rapid input, the >500ms reading-pace gap is the reachable one) and that #1920's E2E-only fix (`InvoicesPage.enableColumn()` awaits the PATCH) makes CI green **without** fixing production — don't close #1955 on a green shard. - **#1957** latent cross-file E2E test-isolation hazard (shared-admin `user_preferences` writes under `fullyParallel`, `LocaleContext.syncWithServer` actively flips a victim test's locale) — Should Have, bug, Backlog, 2026-08-02/03, filed from `/fix-e2e` work on PR #1956. Scoped as an audit + per-spec sweep, not a single-file fix — found a second live instance (`diary-uat-fixes.spec.ts` vs `dashboard.spec.ts`, key `dashboard.hiddenCards`) while researching it. Distinct from #1955 (production race) and #1920 (E2E workaround for #1955). Detail in [e2e-shared-admin-preference-hazard.md](e2e-shared-admin-preference-hazard.md). -- **Bank Report Wizard mini-epic** (no parent epic): #1876 refunds (PR #1880) → #1877 contact/household/attachment typing (PR #1883) → #1878 report backend → #1879 wizard+PDF (PR #1887, CHANGES_REQUIRED then **APPROVED** round 2, 2026-07-30). Domain decisions, contract facts (refund sign, `isSplit`, `totalAmount` scope) and deferrals in [bank-report-wizard.md](bank-report-wizard.md). **Refinement Round 2** (2026-07-31, no parent epic, all Todo): #1898 report table refinements (PR #1902, merged) → #1899 settings step + report language (PR #1903, PO review 2026-07-31: **APPROVED w/ 1 MUST FIX** — 5-step wizard, `getFixedT`/`createFormatters` threading and en/de report output all verified; AC 2.2 default-locale seeding is stale on hard load, see [pr-review-patterns.md](pr-review-patterns.md) `useState(contextValue)` entry) → #1900 editable HTML preview (PR #1909, round 1 COMMENT + 4 MUST FIX, **round 2 2026-07-31 APPROVED** — all 4 verified on disk: local `composes` classes, `--font-weight-medium`, `sharedStyles.srOnly`, translated `resetFieldAriaLabel` at all 9 sites w/ en+de parity; stylelint exit 0. Note: `gh pr review --approve` fails when PO authored the PR context — post verdict via `gh pr comment` with explicit Verdict line. Judgment rulings: signature-derived-from-sender ACCEPTED, mark-claimed-generates-no-PDF ACCEPTED as vacuous, per-field reset ACCEPTED, AC 4.6 rendered-preview assertion ACCEPTED as documented deviation — Playwright headless has no PDF viewer plugin, so the E2E asserts the CSP `frame-src` contract instead; **mixed-language mobile cards ACCEPTED** — see [bank-report-wizard.md](bank-report-wizard.md) "artifact content vs. edit affordance") → #1901 AI usage/cover-letter generation (PR #1916, PO review 2026-07-31: **CHANGES_REQUIRED** — 3 blocking numeric-accuracy defects in the LLM prompt inputs: `/100` on major-unit amounts, `Math.round` to whole euros, per-invoice amount ignoring `excludedLineIds`; + 2 MUST FIX: extraction-flavoured shared LLM error copy, uncommitted wiki API-Contract section. All 6 AC sections otherwise met; entity-level linked-item description deviation ACCEPTED. New defect class recorded in [pr-review-patterns.md](pr-review-patterns.md) "LLM/prompt-assembly defects". **Round 2 on `b70d821b`: APPROVED** — all 5 findings fixed and verified on disk; `prompts.test.ts` gained a dedicated ×100 regression-guard block (98/98 pass locally); per-invoice cents-rounding now makes server math identical to client `applyLineExclusions`; wiki pushed at `254db1d`; the 9 removed test lines were a stale #1915 header note, not a weakened assertion). **Follow-ups consolidated into #1917** (tech-debt, Should Have, Backlog): architect M1–M4 + L1/L2/L3/L5, the `Konstruktionsprojekt`→`Bauprojekt` prompt nit, and the approved `KI` glossary entry. M2 (extract `computeIncludedTotal` to `@cornerstone/shared`) is the headline — the client/server duplication already drifted once and caused the #1916 blocking bug. Open: **#1891** user-verification follow-up (Todo, PR #1894 **APPROVED** 32/32 round 2, 2026-07-30 — 2 wiki MUST FIX outstanding); **#1888** stage-matched attachment indicator (Backlog, blocked-by #1879); **#1895** HIGH claim close-out cross-source sweep, **#1896** quotation-deposit 409 (blocked-by #1895), **#1897** deposit-blind drill-down — all Backlog, from the #1891 architect audit; **#1910** `lang` attribute on report-language preview content (Backlog, a11y follow-up from #1909 round 2); E2E shard 5 pre-existing flake must be triaged before promoting to `main`. **Refinement Round 3** (2026-08-02, from user PDF inspection + wizard walkthrough, all Todo, for `/batch-develop`): **#1929** PDF layout robustness (bug, Must Have — column widths, `dontBreakRows`, header clipped by 40pt top margin; **PR #1935 CHANGES_REQUIRED ×2, AC2-vs-AC4 conflict ruled 2026-08-02: precedence ladder I1 no-loss > I2 no-clip > I3 row-whole > I4 no-word-break; AC2/3/4 rewritten, AC12–AC14 added; 600-char target**), **#1930** attachment tier rules per report type (quotation→deposit→invoice; null = tier `invoice`; supersedes #1888's design question) — **PR #1942 APPROVED round 1, 2026-08-02**, all 11 AC met, 80/80 green; **but #1943** (bug, **Must Have**, Todo, 2026-08-02) — `handleUseCaseChange` never clears `report`/`sourceId`, so budget-overview→claim carries a stale report and can embed **quotation-tier docs in a claim PDF**, reaching #1930 AC2's forbidden outcome by a route AC2 doesn't cover; ruled: clear `sourceId` too — **PR #1942 APPROVED round 1, 2026-08-02**, all 11 AC met, 80/80 green; **#1888 body re-scoped to indicator presentation only at review time** (it was still stale), **#1931** single "Enhance with AI" button + purpose-focused prompt (takes the `Konstruktionsprojekt` nit off #1917), **#1932** cover letter overhaul (folds in #1925, reverses #1909's derived-signature acceptance) — **PR #1951 APPROVED round 1, 2026-08-02**, all 40 ACs met incl. both struck-as-vacuous negative constraints; 3 MUST FIX (German `Mit freundlichen Grüßen` comma, unpinned Closing row, #1925 AC5 editor pin). **#1925 CLOSED as duplicate** (board Wont-Do) — my §6 carried 4 of its 6 ACs, a second instance of the AC-transcription failure mode, **#1933** Select Invoices step UI fixes. Rulings in [bank-report-wizard.md](bank-report-wizard.md) §"Refinement Round 3". **PR #1945 (#1943) review follow-ups filed 2026-08-02**: **#1946** in-flight AI generation survives a use-case change (bug, **Must Have**, Todo — product ruling: widen `guardedUpdate`'s dirty predicate to include `isGeneratingAi`, confirm invalidates via token, cancel lets it finish) and **#1947** `ReportWizardPage` `useReducer` refactor (tech-debt, Should Have, Backlog, blocked-by #1946, filed separately from #1912 on purpose). **M1 was fixed inside PR #1945, not filed** — precedent: a finding that defeats an AC of the story under review belongs in that story's PR regardless of reviewer severity. #1943 AC4 reworded + AC5 enumeration completed (`skippedDocuments`/`aiError` ruled CLEAR, carried as #1946 AC9/AC10); #1933 AC2.1/2.7 corrected (invoice row has no mobile card). See [bank-report-wizard.md](bank-report-wizard.md) §"#1945 review follow-ups" and §"#1933 ACs 2.1/2.7" — the latter records a **recurring AC-writing failure mode** (ACs that misdescribe reality fail correct implementations at UAT). **#1929 CLOSED 2026-08-02** — PR #1935 merged (squash `1c5aa62c`) after **4 rounds**; both reviewers measured by real render+rasterize. 5 follow-ups filed: **#1937** German header labels break mid-word (bug, Todo, translator fast-follow — widening measured and rejected), **#1938** running-header `generated at` label with no timestamp on pages 2+ (bug, Todo, **pre-existing**), **#1939** reportPdf geometry hygiene (tech-debt, Todo, **blocks #1932** — `HEADER_ROW_HEIGHT`→`_MAX` 68pt vs measured 45.81pt, char-advance comment scoping, `PDF_STYLES` relocation), **#1940** continuation rows read as broken (could have, Backlog), **#1941** override fields have no `maxLength` (could have, Backlog), **#1950** guard test recomputing the derived `Ѹ` ceiling (tech-debt, could have, Backlog, blocked-by #1939 — filed 2026-08-02 from PR #1948 §2; architect reframed its own ask from "re-run the 3,919-codepoint sweep" to "a test that recomputes 616 from `USAGE_WIDTH_7COL`/font sizes/`DEFAULT_LINE_HEIGHT`", sweep now an explicit non-goal. Rulings: **comment keeps the rationale, issue owns the guard, neither replaces the other**; bounded-and-quantified earns a tracked owner where unbounded-and-estimated gets documentation only; a derived bound with no test is a comment waiting to go stale — `overviewPdf.test.ts` pins `704`/`546` as literals tied to no geometry constant). `markerText`+`invoiceNumber` folded into #1939 as documentation-only; vendor-name mid-word break recorded as accepted limitation in #1937. Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1929 closed". **#1931 PR #1944 APPROVED round 1, 2026-08-02** — all ACs met **except 3.2/3.3, deliberately NOT claimed**: they assert live-model output quality, which a mocked LLM cannot verify. Ruling: **merge is a code gate, Done is an acceptance gate** — PR merges, story stays out of Done until a human reads real EN+DE output with `LLM_*` set; UAT scenarios posted on #1931; failure → reopen #1931, don't file a follow-up. Contrast #1909 AC 4.6: an unverifiable AC **with** a substitute assertion may be waived as a documented deviation; **without** one it goes to UAT. "Mit KI verbessern" accepted for AC 2.3. **#1917 L3 struck** (verified fixed); rest of #1917 open, **`KI` glossary entry still #1917's** — `glossary.json` untouched by #1944. Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1931 reviewed". -- **PR #1959 (report PDF UX, user's own PR, held promotion #1958) — 3 rulings 2026-08-03.** Footnotes `†`/`‡` + their sentences replaced by inline labels `(partial)`/`(less deposit)`, reversing **#1923 AC1.1/1.2/2.3/2.4** (which had already superseded #1898 §4). **Ruled: labels kept, sentences return as a non-editable report-level legend → #1965 (Must Have)** — `(partial)` is near-self-evident beside the two amount columns, but `(less deposit)` drops "claimed **separately**", a materially different audit claim. Cheap because the footnote channel is orphaned-not-removed (producer-only fix). **Closed/released ACs get a dated supersession comment, never a rewrite** (posted on #1898 + #1923). #1923 AC5.3 substance survives — `areaText` still a separate field, so judged against its stated rationale, not its prescribed sub-line rendering. Glossary: **`Abschlag` APPROVED** as a measured-space short form of `Abschlagszahlung` (option (c) is arithmetically impossible — the full term eats 72.85 of 75pt, so no qualifier fits; collapsing into the constituted-deposit label would lose more); **`split`'s three German forms get NO entry** (glossary prevents semantic divergence, is not a surface-form registry). Both on **#1917**. Issues filed: **#1965** legend, **#1966** column-toggle E2E, **#1967** `attachmentsNote` override unreachable, **#1968** meta-suffix single run, **#1969** `testPrefix`/`authenticatedPage`, **#1970** configurable auth rate limits, **#1971** `search-users.spec.ts` leftovers, **#1972** silent failed column-pref saves + dead `isLoaded`. Full rulings in [bank-report-wizard.md](bank-report-wizard.md) §"PR #1959 rulings". +- **Bank Report Wizard mini-epic** (no parent epic) — all rulings, contract facts, per-PR review outcomes and filed follow-ups in [bank-report-wizard.md](bank-report-wizard.md). Shipped: #1876→#1877→#1878→#1879, Round 2 #1898–#1901, Round 3 #1929–#1933 (all merged; #1929 took 4 rounds, #1925 closed as duplicate). **Open**: #1888 indicator, #1891 (2 wiki MUST FIX), #1895→#1896/#1897 claim close-out, #1910 `lang` attr, #1917 consolidated follow-ups (incl. `KI` glossary entry + `computeIncludedTotal` extraction), #1937/#1938 PDF header bugs, #1939 geometry hygiene, #1940/#1941/#1950, #1946 in-flight AI generation (Must Have), #1947 `useReducer`, #1952/#1953, #1965–#1972 (PR #1959 sweep). #1931 merged but **not Done** — ACs 3.2/3.3 need live-LLM UAT. +- **Reusable rulings from this cluster** (detail in [bank-report-wizard.md](bank-report-wizard.md), patterns in [pr-review-patterns.md](pr-review-patterns.md)): **merge is a code gate, Done is an acceptance gate** (unverifiable AC *with* a substitute assertion = documented deviation; *without* one → UAT, reopen on failure); **a finding that defeats the PR's own AC belongs in that PR, not a follow-up**; **closed/released ACs get a dated supersession comment, never a rewrite**; **ACs that misdescribe reality fail correct implementations at UAT** (seen 3×: #1943 AC4, #1933 AC2.1/2.7, my own #1925/#1932 transcription); **comment keeps the rationale, issue owns the guard**; bounded-and-quantified earns a tracked owner, unbounded-and-estimated gets documentation only. ## Requirements Coverage diff --git a/.claude/agent-memory/product-owner/auth-rate-limits-1970.md b/.claude/agent-memory/product-owner/auth-rate-limits-1970.md new file mode 100644 index 000000000..6161eca83 --- /dev/null +++ b/.claude/agent-memory/product-owner/auth-rate-limits-1970.md @@ -0,0 +1,102 @@ +--- +name: auth-rate-limits-1970 +description: Story #1970 (configurable auth rate limits) and the two-round PR #1989 review — rulings, filed follow-ups, and the AC-assertion gap that keeps it out of Done +metadata: + type: project +--- + +# #1970 — configurable login rate limits (`AUTH_RATE_LIMIT_MAX` / `_WINDOW`) + +Standalone story, no parent epic. Should Have. Filed 2026-08-03 out of the PR #1959 ruling +sweep. Implemented in PR #1989 (`feat/1970-auth-rate-limits-configurable`). + +**Why it exists:** self-hosted households behind one NAT share a rate-limit bucket, so +legitimate family retries can lock out login; internet-exposed instances want the opposite. +20/15min was hardcoded at `auth.ts:139`. The old security-hygiene home (#315) is CLOSED. + +## Review round 1 (2026-08-03) — CHANGES_REQUIRED + +6 of 7 ACs met. Blocking: `AUTH_RATE_LIMIT_WINDOW=0s` passed validation — `max` rejected +`<= 0`, the window was pattern-matched with no bound on the resulting duration. Defeated AC2 +("does not silently disable the limit") and AC7 ("no value that removes the limit entirely"). +Medium (M1): nothing proved the *window* reached the route. + +Rulings made in round 1: + +- **AC5** ("documented in CLAUDE.md **and** on the docs site — file a request if needed") is + satisfiable by filing the request → **#1990** (docs-writer, Todo, blocked-by #1970). Must + cross-reference `TRUST_PROXY`: it decides whether the bucket keys on the real client IP or + the proxy's, and the NAT operator needs both settings together. +- **AC6** (setup route stays hardcoded) ACCEPTED. The store is in-memory, so a restart clears + the bootstrap limiter, and `/setup` 403s unconditionally once setup is complete — tuning it + has no operational value. Rationale comment at `auth.ts:76-78`. +- **`parseInt` leniency** ruled house convention, out of scope for this story. + +## Review round 2 (`47ee190`, 2026-08-04) — APPROVED with one MUST FIX + +**B1 resolved.** `config.ts:371-375` adds `else if (parseFloat(str) <= 0)`, rejecting `0s`, +`0 minutes`, `0.0h`, `00 minutes` with a message naming the variable (3 tests in +`config.test.ts:1093-1109`). The pattern also changed `\s*` → ` *` (`config.ts:365`), which +closes the second instance of the same drift class: `15\tminutes` no longer validates. + +**My round-1 mechanism was wrong** — see [pr-review-patterns.md](pr-review-patterns.md) +§"Configurable security controls". I claimed silent disable via `LocalStore.incr`; the verified +behaviour (product-architect, against `node_modules`) is `parse('0s') → undefined` → +`mergeParams()`'s `if/else if` never reaches `defaultTimeWindow` → **every login 500s** on +`params.timeWindow is not a function`. Do not cite my round-1 comment as the mechanism. + +**M1 (was MUST FIX).** The round-2 assertions were on `x-ratelimit-limit` (`'3'`, `'20'`), fed by +`max` only. Deleting `timeWindow` from `auth.ts:147` inherits the global `'1 minute'` and leaves +both green — exactly the drift AC1 and AC4 were written to catch. Fix specified as one line on +the default-config test: `expect(response.headers['x-ratelimit-reset']).toBe('900')` +(`Math.ceil(ttl/1000)`, `index.js:265`; emitted on non-exceeded responses too). + +**Gate ruling: merge is a code gate, Done is an acceptance gate.** The PR may merge on B1; +#1970 does **not** go to Done until the window assertion lands. If it merges without, +**reopen #1970** rather than filing a follow-up (same precedent as #1931). + +## Review round 3 (`5446b29a`, 2026-08-04) — APPROVED, M1 RESOLVED + +Fix applied verbatim at `rateLimitPlugin.test.ts:157`. All 7 ACs met, **no unverifiable AC +remains** → the Done gate above is satisfied on both sides: #1970 goes to **Done on merge**, no +reopen, no substitute follow-up. Follow-ups #1990/#1991/#1992 stay independent and don't gate. + +Two things worth reusing: + +- **Verify a "proves X reached the route" assertion by mutation, not by reading it.** I deleted + `timeWindow` from `auth.ts:147` locally, ran the one test file, got + `Expected: "900" / Received: "60"`, then `git checkout -- server/src/routes/auth.ts`. That is + the only evidence that distinguishes a load-bearing assertion from one that merely *looks* + specific. Reverting immediately keeps it inside PO boundaries — it is verification, not + authoring. Generalized in [pr-review-patterns.md](pr-review-patterns.md). +- **Check the header is deterministic before accepting it as an AC probe.** `LocalStore.incr` + sets `ttl: timeWindow` exactly on the *first* request of a fresh window + (`store/LocalStore.js:17`), and each test builds its own app → fresh in-memory store. So `900` + is exact, not timing-sensitive. Had the assertion been on a *later* request, ttl would be + `timeWindow - elapsed` (`LocalStore.js:38`) and the same assertion would have been a flake. + When approving a numeric-header probe, ask which request in the window it observes. + +## Follow-ups filed from this review + +| Issue | Kind | Status | Substance | +| --- | --- | --- | --- | +| **#1990** | user-story, Should Have | Todo | Docs-site rate-limit copy (docs-writer), must cross-reference `TRUST_PROXY` | +| **#1991** | tech-debt, Could Have | Backlog | Uniform integer parsing across the 8 `parseInt` sites in `loadConfig()` — the tracked home for the leniency ruling | +| **#1992** | documentation, Should Have | Todo | Wiki documents a nonexistent `OIDC_REDIRECT_URI` and a four-variable OIDC gate; `config.ts:142` gates on three | + +**#1991 rationale:** the ruling stays "out of scope for #1970", but `product-architect` +(Medium) and `security-engineer` (Low) both raised it independently, so a review comment alone +guarantees a fourth reviewer raises it again. A local `/^\d+$/` guard on one variable would +leave seven inconsistent siblings. Same shape as the #1950 ruling. + +**#1992 rationale:** the architect's wiki commit `5c1c7e71` flagged the deviation in the +API-Contract Deviation Log as an explicit unresolved follow-up but nothing tracked it. Verified +real. `CLAUDE.md` and the docs site are already correct → wiki-only fix (2 pages), owned by +product-architect. + +Wiki MEDIUM from round 1 resolved: submodule bumped to `5c1c7e71`, verified on `origin/master`; +both auth tables carry the new rows, `TRUST_PROXY` backfilled, plus a "Rate Limiting (Auth)" +subsection. + +`gh pr review --approve` refused again ("Can not approve your own pull request") — verdict +posted via `gh pr comment` with an explicit `## Verdict:` line. diff --git a/.claude/agent-memory/product-owner/bank-report-wizard.md b/.claude/agent-memory/product-owner/bank-report-wizard.md index a0596a427..0d441b3aa 100644 --- a/.claude/agent-memory/product-owner/bank-report-wizard.md +++ b/.claude/agent-memory/product-owner/bank-report-wizard.md @@ -443,3 +443,18 @@ Option (b), widening the column, rejected on **risk not cost**: `ALLOCATED_AMOUN ### Glossary: `split`'s three German forms — NO entry, deliberately `anteilig` (adj.) / `Anteil` (noun) / `Teilbetrag` (noun), each role-correct, mirroring English's own `partial`/`split`/`portion`. **Ruled: not drift, no entry.** The glossary prevents *semantic* divergence — one concept becoming two concepts. It is **not a single-surface-form registry**, and pinning one form here would force ungrammatical copy across an adjective and two nouns. Recorded on #1917 as "reject if proposed later" so a future translator does not re-escalate. Revisit only if the *English* is unified — a copy story, not a glossary one. + +### Issues filed from the PR #1959 sweep (2026-08-03) + +All parentless, Bank Report Wizard cluster. #1959 was the user's own PR and held promotion #1958. + +| Issue | Substance | +| --- | --- | +| **#1965** | Report-level, non-editable legend restoring the `(partial)` / `(less deposit)` explanatory sentences (Must Have; producer-only fix — the footnote channel is orphaned, not removed) | +| **#1966** | E2E coverage for the column toggles | +| **#1967** | `attachmentsNote` override is unreachable | +| **#1968** | Meta-suffix emitted as a single run | +| **#1969** | `testPrefix` / `authenticatedPage` fixture cleanup | +| **#1970** | Configurable auth rate limits — see [auth-rate-limits-1970.md](auth-rate-limits-1970.md) | +| **#1971** | `search-users.spec.ts` leftovers | +| **#1972** | Column-preference saves fail silently + dead `isLoaded` | diff --git a/.claude/agent-memory/product-owner/pr-review-patterns.md b/.claude/agent-memory/product-owner/pr-review-patterns.md index 9419a5ae5..b8964b2a8 100644 --- a/.claude/agent-memory/product-owner/pr-review-patterns.md +++ b/.claude/agent-memory/product-owner/pr-review-patterns.md @@ -120,14 +120,28 @@ When a PR adds a mobile card list beside a desktop table, re-check rather than a ## Prompt-stated constraints with no enforced counterpart (PR #1951, #1952) -- **A constraint asserted only in the prompt is not a guarantee.** Pattern to check on any LLM story: for each behavioural rule the prompt states, ask what fails if the model ignores it. `prompts.test.ts`-style tests pin that the *instruction exists*, which is real coverage of the instruction and zero coverage of the outcome. #1931 got this right by construction (stated caps derived from `REPORT_CONTENT_LIMITS`); #1932's AC 1.6 plain-prose rule had no enforced side at all, and the render path (`applyAiContent.ts` → pdfmake `{ text }`) is literal, so `**bold**` reaches a bank-facing PDF. -- **Coerce vs reject at an LLM boundary — match the policy the field already has, and weigh blast radius per call.** One generation often produces several unrelated outputs (subject + body + all per-invoice descriptions). Rejecting the whole response over a cosmetic defect in one field discards correct expensive output and may fail identically on retry. If the validator already *truncates* rather than throws on that field, a stricter failure mode for a *milder* violation is incoherent. Ruled *strip, not reject* on #1952. +- **A constraint asserted only in the prompt is not a guarantee.** Pattern to check on any LLM story: for each behavioural rule the prompt states, ask what fails if the model ignores it. `prompts.test.ts`-style tests pin that the _instruction exists_, which is real coverage of the instruction and zero coverage of the outcome. #1931 got this right by construction (stated caps derived from `REPORT_CONTENT_LIMITS`); #1932's AC 1.6 plain-prose rule had no enforced side at all, and the render path (`applyAiContent.ts` → pdfmake `{ text }`) is literal, so `**bold**` reaches a bank-facing PDF. +- **Coerce vs reject at an LLM boundary — match the policy the field already has, and weigh blast radius per call.** One generation often produces several unrelated outputs (subject + body + all per-invoice descriptions). Rejecting the whole response over a cosmetic defect in one field discards correct expensive output and may fail identically on retry. If the validator already _truncates_ rather than throws on that field, a stricter failure mode for a _milder_ violation is incoherent. Ruled _strip, not reject_ on #1952. - **When the hardening is a text transform, the false positives are the risk.** Domain punctuation collides with markup characters (`Pos. 3 - Dachstuhl`, `Rechnung #2024-117`, `Beträge < 500 EUR`, footnote `*`). A mangled reference number is worse than the markup, because nothing signals a character went missing. Write as many byte-identical-passthrough ACs as stripping ACs, plus "if the transform empties a non-empty value, keep the original". -- **Amend an honest interim doc statement, don't delete it.** When a PR documents "instructed but not enforced" and a follow-up adds enforcement, the follow-up AC must say *amend that bullet* — otherwise the page keeps a stale "not enforced" line. +- **Amend an honest interim doc statement, don't delete it.** When a PR documents "instructed but not enforced" and a follow-up adds enforcement, the follow-up AC must say _amend that bullet_ — otherwise the page keeps a stale "not enforced" line. ## Shared-constant challenges: DRY vs semantic identity (PR #1951, #1953) -- **"Don't repeat the literal" is not "these are the same value."** When a reviewer challenges a shared constant, read the *sharing rationale*. A spec saying "don't hand-write `fontSize: 12` as a second copy" argues against a magic literal; it does not claim the two consumers are semantically linked. Only the latter justifies keeping the share. On #1953 the equality was ruled **coincidental** → independent literal, and the tempting `const NEW = OLD;` alias was ruled **out** because it fixes the name while preserving the exact coupling that is the problem. -- **Constant-drift has two directions and both need filing.** Two drifting copies of one value (#1939's class) *and* one shared name over two coincidentally-equal values (#1953's class) produce the same symptom: an edit with a consequence the author never looked at. Watch for the second reintroducing itself right after a cleanup of the first. +- **"Don't repeat the literal" is not "these are the same value."** When a reviewer challenges a shared constant, read the _sharing rationale_. A spec saying "don't hand-write `fontSize: 12` as a second copy" argues against a magic literal; it does not claim the two consumers are semantically linked. Only the latter justifies keeping the share. On #1953 the equality was ruled **coincidental** → independent literal, and the tempting `const NEW = OLD;` alias was ruled **out** because it fixes the name while preserving the exact coupling that is the problem. +- **Constant-drift has two directions and both need filing.** Two drifting copies of one value (#1939's class) _and_ one shared name over two coincidentally-equal values (#1953's class) produce the same symptom: an edit with a consequence the author never looked at. Watch for the second reintroducing itself right after a cleanup of the first. - **Record a reviewer's deferral trigger in the code, not only in the issue.** When an architect says "split when a second X appears, not now", the trigger belongs in the file header where the next author reads it. Companion to #1950's rule: the comment owns the rationale, the issue owns the guard, neither replaces the other. - **When a reviewer claims a follow-up as its own, verify it exists before assuming.** Architect said it would file the `react/no-danger` rule itself; it hadn't (checked the latest repo issue number). Prompt on the PR rather than filing a duplicate. + +## Configurable security controls: the "off" value nobody asked for (PR #1989, #1970) + +- **When a story makes a security control configurable, enumerate the values that turn it OFF — those are the ACs that matter.** #1970's AC2 ("does not silently disable the limit") + AC7 ("no value that removes the limit entirely") were both defeated by `AUTH_RATE_LIMIT_WINDOW=0s`. The validation was **asymmetric**: `max` explicitly rejected `<= 0`, the window was pattern-matched only with no bound on the resulting duration. Generalisable check: a bounds check on one half of a two-part limit and not the other is a defect, not a style nit. +- **`timeWindow: '0s'` makes every login request 500** (`@fastify/rate-limit` v11 + `@lukeed/ms`). CORRECTED in round 2: my round-1 mechanism (`LocalStore.incr` resetting the counter every request, i.e. silent disable) was **wrong**; `product-architect`'s was right and verified against `node_modules`. `parse('0s')` returns `undefined` because its guard is `if (arr != null && (num = parseFloat(arr[1])))` and `0` is falsy; `mergeParams()`'s `if / else if` chain therefore never falls through to `defaultTimeWindow`, and request time does `await params.timeWindow(req, key)` on `undefined` → `params.timeWindow is not a function`. Same AC2 violation, worse blast radius. **Lesson: a store-level mechanism argument read off one file is not verification — the value passes through `mergeParams()` first.** Trace the whole path (route options → `mergeParams` → store) before naming a mechanism in a review, or the fix gets designed against the wrong failure. +- **`max` and `timeWindow` are independent branches in `mergeParams()`** (`node_modules/@fastify/rate-limit/index.js:163-175`), and route options merge over `globalParams` via `Object.assign`. So an assertion on `x-ratelimit-limit` proves **only** `max`; deleting the route's `timeWindow` line silently inherits the global window with that assertion still green. PR #1989 round 2 shipped exactly that and its commit message claimed it closed the window gap — it did not. **When an author reports a verification gap as fixed, re-derive the mutation yourself** ("delete the wiring line — does this specific assertion fail?"); a plausible-sounding fix to an assertion gap is the easiest thing to wave through twice. +- **Hand-rolled regex duplicating a library's grammar drifts in BOTH directions.** The `ms`-format regex accepted `0.5ms` (useless) and rejected `1y` (valid `ms`). Prefer "call the library, require a positive finite result" — one check instead of a guard beside a duplicated grammar. +- **A "house convention" ruling still deserves a tracked owner when three reviewers independently trip on it.** Round 2 filed **#1991** (tech-debt, Could Have, Backlog) for uniform integer parsing across the eight `parseInt` call sites in `loadConfig()` — the ruling stays "out of scope for #1970", but `product-architect` (Medium) and `security-engineer` (Low) both raised it, so leaving it purely as a review comment guarantees a fourth reviewer raises it again. Same shape as the #1950 ruling: bounded-and-quantified earns a tracked owner. +- **Before flagging leniency, check whether it is the house convention.** `parseInt` + `isNaN || <= 0` lets `20abc`→20 and `1e9`→1 through, but `BACKUP_RETENTION`, `LLM_MAX_TOKENS`, and `LLM_REQUEST_TIMEOUT_MS` in `config.ts` all use the identical form. Tightening one of four makes the file *less* consistent → ruled explicitly out of scope and labelled informational. Grep the sibling cases in the same file before writing a finding; distinguish "misparse still yields a working control" from "yields no control". +- **"Asserted by a test that observes the effective limit" means: would this test fail if the wiring line were deleted?** #1989 proved `max` end-to-end (set to 3, 4th request 429s) but nothing proved `timeWindow` reached the route — deleting it would fall back to the global `1 minute` with every test still green. `x-ratelimit-reset` = `Math.ceil(ttl/1000)` makes the window observable (`30s` → ~30 vs global default ~60). Same family as the "assertions that pass on nothing" pattern: the header test asserted only `toBeDefined()`. +- **Run the mutation, don't read the assertion.** Round 3 (`5446b29a`) closed the gap with `expect(response.headers['x-ratelimit-reset']).toBe('900')`. I verified it by *deleting* `timeWindow` from `auth.ts:147` locally, running the single test file (`Expected: "900" / Received: "60"`), then `git checkout -- server/src/routes/auth.ts`. Reading a specific-looking assertion cannot distinguish load-bearing from decorative — round 2 is proof, since I nearly waved through an assertion that looked equally specific. Mutate + revert stays inside PO boundaries: it is verification, not authoring. Do this whenever an AC's evidence is of the form "this test proves X reached Y". +- **Before accepting a numeric-header probe, ask which request in the window it observes.** `x-ratelimit-reset` is exact (`900`) only on the **first** request of a fresh window — `LocalStore.incr` sets `ttl: timeWindow` verbatim (`store/LocalStore.js:17`); on any later request it is `timeWindow - elapsed` (`:38`), where the same equality assertion would be a flake. #1989 is safe because each test builds its own app → fresh in-memory store. A derived-value assertion can be both meaningful *and* flaky; check determinism separately from meaningfulness. +- **An AC of the form "documented in CLAUDE.md AND on the docs site (file a request if needed)" is satisfiable by filing the request** — so file it during review instead of blocking on it. Filed **#1990** (docs-writer, Todo, blocked-by #1970), carrying forward #1970's Notes requirement to cross-reference `TRUST_PROXY` (it decides whether the limit buckets on the real client IP or the proxy's — the shared-IP operator needs both settings). +- **`gh pr review` cannot be used when the PR author is the token owner** (the user's own PRs). Post the verdict via `gh pr comment` with an explicit `## Verdict:` line — same workaround already recorded for #1909. diff --git a/CLAUDE.md b/CLAUDE.md index 8a0dbb27f..af54b3d7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -542,6 +542,8 @@ Hand-written SQL files in `server/src/db/migrations/` with a numeric prefix (e.g | `SESSION_DURATION` | `604800` | Session duration in seconds (default: 7 days) | | `SECURE_COOKIES` | `true` | Enable HTTPS-only cookie flag | | `TRUST_PROXY` | `false` | Trust X-Forwarded-\* headers from a reverse proxy | +| `AUTH_RATE_LIMIT_MAX` | `20` | Login endpoint rate limit: max requests per IP per window (positive integer) | +| `AUTH_RATE_LIMIT_WINDOW` | `15 minutes` | Login endpoint rate limit: time window (ms library format, e.g. `15 minutes`, `1h`, `30s`) | | `OIDC_ISSUER` | (none) | OpenID Connect issuer URL | | `OIDC_CLIENT_ID` | (none) | OIDC application client ID | | `OIDC_CLIENT_SECRET` | (none) | OIDC application client secret | diff --git a/server/src/plugins/config.test.ts b/server/src/plugins/config.test.ts index fa5ca038d..f7d25fbc9 100644 --- a/server/src/plugins/config.test.ts +++ b/server/src/plugins/config.test.ts @@ -49,6 +49,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', }); }); @@ -99,6 +101,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', }); }); }); @@ -151,6 +155,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', }); }); @@ -198,6 +204,8 @@ describe('Configuration Module - loadConfig() Pure Function', () => { llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', }); }); }); @@ -992,6 +1000,114 @@ describe('Configuration Module - loadConfig() Pure Function', () => { } }); }); + + // ─── Issue #1970: AUTH_RATE_LIMIT_MAX and AUTH_RATE_LIMIT_WINDOW ────────── + + describe('AUTH_RATE_LIMIT_MAX and AUTH_RATE_LIMIT_WINDOW Configuration (Issue #1970)', () => { + it('AUTH_RATE_LIMIT_MAX unset → authRateLimitMax defaults to 20', () => { + const config = loadConfig({}); + expect(config.authRateLimitMax).toBe(20); + }); + + it('AUTH_RATE_LIMIT_WINDOW unset → authRateLimitWindow defaults to "15 minutes"', () => { + const config = loadConfig({}); + expect(config.authRateLimitWindow).toBe('15 minutes'); + }); + + it('AUTH_RATE_LIMIT_MAX=50 → authRateLimitMax equals 50', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_MAX: '50' }); + expect(config.authRateLimitMax).toBe(50); + }); + + it('AUTH_RATE_LIMIT_WINDOW="1h" → authRateLimitWindow equals "1h"', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_WINDOW: '1h' }); + expect(config.authRateLimitWindow).toBe('1h'); + }); + + it('AUTH_RATE_LIMIT_WINDOW="30 minutes" → authRateLimitWindow equals "30 minutes"', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_WINDOW: '30 minutes' }); + expect(config.authRateLimitWindow).toBe('30 minutes'); + }); + + it('AUTH_RATE_LIMIT_WINDOW="30s" → authRateLimitWindow equals "30s"', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_WINDOW: '30s' }); + expect(config.authRateLimitWindow).toBe('30s'); + }); + + it('AUTH_RATE_LIMIT_MAX=abc → throws containing "AUTH_RATE_LIMIT_MAX must be a positive integer, got: abc"', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_MAX: 'abc' })).toThrow( + 'AUTH_RATE_LIMIT_MAX must be a positive integer, got: abc', + ); + }); + + it('AUTH_RATE_LIMIT_MAX=0 → throws containing "AUTH_RATE_LIMIT_MAX must be a positive integer, got: 0"', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_MAX: '0' })).toThrow( + 'AUTH_RATE_LIMIT_MAX must be a positive integer, got: 0', + ); + }); + + it('AUTH_RATE_LIMIT_MAX=-1 → throws containing "AUTH_RATE_LIMIT_MAX must be a positive integer, got: -1"', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_MAX: '-1' })).toThrow( + 'AUTH_RATE_LIMIT_MAX must be a positive integer, got: -1', + ); + }); + + it('AUTH_RATE_LIMIT_WINDOW=not-a-duration → throws containing "AUTH_RATE_LIMIT_WINDOW must be a valid duration string"', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_WINDOW: 'not-a-duration' })).toThrow( + 'AUTH_RATE_LIMIT_WINDOW must be a valid duration string', + ); + }); + + it('AUTH_RATE_LIMIT_WINDOW="5 minutes foo" → throws containing "AUTH_RATE_LIMIT_WINDOW must be a valid duration string"', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_WINDOW: '5 minutes foo' })).toThrow( + 'AUTH_RATE_LIMIT_WINDOW must be a valid duration string', + ); + }); + + it('empty string AUTH_RATE_LIMIT_MAX treated as missing → authRateLimitMax defaults to 20', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_MAX: '' }); + expect(config.authRateLimitMax).toBe(20); + }); + + it('empty string AUTH_RATE_LIMIT_WINDOW treated as missing → authRateLimitWindow defaults to "15 minutes"', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_WINDOW: '' }); + expect(config.authRateLimitWindow).toBe('15 minutes'); + }); + + it('both invalid vars in one call → single throw listing both errors', () => { + expect(() => + loadConfig({ + AUTH_RATE_LIMIT_MAX: 'abc', + AUTH_RATE_LIMIT_WINDOW: 'not-a-duration', + }), + ).toThrow( + "Configuration validation failed:\n - AUTH_RATE_LIMIT_MAX must be a positive integer, got: abc\n - AUTH_RATE_LIMIT_WINDOW must be a valid duration string (e.g. '15 minutes', '1h'), got: not-a-duration", + ); + }); + + it('AUTH_RATE_LIMIT_MAX=1 (minimum valid) → authRateLimitMax equals 1', () => { + const config = loadConfig({ AUTH_RATE_LIMIT_MAX: '1' }); + expect(config.authRateLimitMax).toBe(1); + }); + + it('AUTH_RATE_LIMIT_WINDOW="0s" → throws containing zero magnitude error', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_WINDOW: '0s' })).toThrow( + 'AUTH_RATE_LIMIT_WINDOW must have a positive duration (zero magnitude is not allowed), got: 0s', + ); + }); + + it('AUTH_RATE_LIMIT_WINDOW="0 minutes" → throws containing zero magnitude error', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_WINDOW: '0 minutes' })).toThrow( + 'AUTH_RATE_LIMIT_WINDOW must have a positive duration (zero magnitude is not allowed), got: 0 minutes', + ); + }); + + it('AUTH_RATE_LIMIT_WINDOW="0.0h" → throws containing zero magnitude error (parseFloat gives 0)', () => { + expect(() => loadConfig({ AUTH_RATE_LIMIT_WINDOW: '0.0h' })).toThrow( + 'AUTH_RATE_LIMIT_WINDOW must have a positive duration (zero magnitude is not allowed), got: 0.0h', + ); + }); + }); }); describe('Configuration Module - Fastify Plugin Integration', () => { diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index 646309c98..cb62d222b 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -52,6 +52,10 @@ export interface AppConfig { autoItemizeEnabled: boolean; /** Alias of autoItemizeEnabled — clearer name for LLM capabilities. Story #1901. */ llmEnabled: boolean; + /** Maximum login requests per IP per authRateLimitWindow. Default: 20. */ + authRateLimitMax: number; + /** Time window for login rate limiting (ms library format, e.g. '15 minutes'). Default: '15 minutes'. */ + authRateLimitWindow: string; } // Type augmentation: makes fastify.config available across all routes/plugins @@ -347,6 +351,28 @@ export function loadConfig(env: Record): AppConfig { else if (url.includes(':11434') || /\bollama\b/.test(url)) llmProvider = 'ollama'; } + // AUTH_RATE_LIMIT_MAX — maximum login requests per IP per window (positive integer) + const authRateLimitMaxStr = getValue('AUTH_RATE_LIMIT_MAX') ?? '20'; + const authRateLimitMax = parseInt(authRateLimitMaxStr, 10); + if (isNaN(authRateLimitMax) || authRateLimitMax <= 0) { + errors.push(`AUTH_RATE_LIMIT_MAX must be a positive integer, got: ${authRateLimitMaxStr}`); + } + + // AUTH_RATE_LIMIT_WINDOW — time window for login rate limiting (ms library format) + // Accepts: '15 minutes', '1h', '30s', '2 days', etc. + const authRateLimitWindowStr = getValue('AUTH_RATE_LIMIT_WINDOW') ?? '15 minutes'; + const AUTH_RATE_LIMIT_WINDOW_PATTERN = + /^\d+(\.\d+)? *(ms|s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$/i; + if (!AUTH_RATE_LIMIT_WINDOW_PATTERN.test(authRateLimitWindowStr)) { + errors.push( + `AUTH_RATE_LIMIT_WINDOW must be a valid duration string (e.g. '15 minutes', '1h'), got: ${authRateLimitWindowStr}`, + ); + } else if (parseFloat(authRateLimitWindowStr) <= 0) { + errors.push( + `AUTH_RATE_LIMIT_WINDOW must have a positive duration (zero magnitude is not allowed), got: ${authRateLimitWindowStr}`, + ); + } + // If there are any validation errors, throw a single error listing all of them if (errors.length > 0) { throw new Error(`Configuration validation failed:\n - ${errors.join('\n - ')}`); @@ -389,6 +415,8 @@ export function loadConfig(env: Record): AppConfig { llmProvider, autoItemizeEnabled, llmEnabled: autoItemizeEnabled, // Alias for clearer naming + authRateLimitMax, + authRateLimitWindow: authRateLimitWindowStr, }; } @@ -425,6 +453,8 @@ export default fp( autoItemizeEnabled: config.autoItemizeEnabled, llmProvider: config.llmProvider, llmMaxTokens: config.llmMaxTokens, + authRateLimitMax: config.authRateLimitMax, + authRateLimitWindow: config.authRateLimitWindow, }, 'Configuration loaded', ); diff --git a/server/src/plugins/rateLimitPlugin.test.ts b/server/src/plugins/rateLimitPlugin.test.ts index 2cdb3c7bc..c4a1d9810 100644 --- a/server/src/plugins/rateLimitPlugin.test.ts +++ b/server/src/plugins/rateLimitPlugin.test.ts @@ -85,3 +85,88 @@ describe('Rate Limit Plugin', () => { expect(body.error.message).toContain('Too many requests'); }); }); + +describe('Login Route Rate Limiting — Configurable via Env (Issue #1970)', () => { + let app: FastifyInstance | undefined; + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + tempDir = mkdtempSync(join(tmpdir(), 'cornerstone-ratelimit-configurable-test-')); + process.env.DATABASE_URL = join(tempDir, 'test.db'); + process.env.SECURE_COOKIES = 'false'; + app = undefined; + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + process.env = originalEnv; + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('AC3: configured max exceeded → 429 with RATE_LIMIT_EXCEEDED', async () => { + process.env.AUTH_RATE_LIMIT_MAX = '3'; + app = await buildApp(); + + // Make 3 requests — each returns 401 (wrong credentials) but counts toward limit + for (let i = 0; i < 3; i++) { + await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'test@x.com', password: 'wrong' }, + }); + } + + // 4th request exceeds the configured limit of 3 + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'test@x.com', password: 'wrong' }, + }); + + expect(response.statusCode).toBe(429); + expect(JSON.parse(response.body).error.code).toBe('RATE_LIMIT_EXCEEDED'); + // Prove the configured max reached the route: header on the 429 response reflects limit=3 + expect(response.headers['x-ratelimit-limit']).toBe('3'); + }); + + it('AC4: defaults are exactly max=20 and window="15 minutes" and route uses them', async () => { + // No AUTH_RATE_LIMIT_MAX or AUTH_RATE_LIMIT_WINDOW in env + app = await buildApp(); + + expect(app.config.authRateLimitMax).toBe(20); + expect(app.config.authRateLimitWindow).toBe('15 minutes'); + + // Prove the route actually uses the configured max: x-ratelimit-limit must equal '20' + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'test@x.com', password: 'wrong' }, + }); + expect(response.headers['x-ratelimit-limit']).toBe('20'); + // Prove the configured window reached the route: reset = ceil(900_000ms / 1000) = 900s + // If timeWindow were deleted from auth.ts the route would inherit the global '1 minute' + // default and this header would be '60', not '900'. + expect(response.headers['x-ratelimit-reset']).toBe('900'); + }); + + it('rate-limit headers present on login route', async () => { + app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { email: 'test@x.com', password: 'wrong' }, + }); + + expect(response.headers['x-ratelimit-limit']).toBeDefined(); + expect(response.headers['x-ratelimit-remaining']).toBeDefined(); + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 0d37ea713..f204745e1 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -73,6 +73,9 @@ export default async function authRoutes(fastify: FastifyInstance) { * Creates the first admin user. Only works when no users exist. * After setup is complete, returns 403 SETUP_COMPLETE. */ + // Rate limit is intentionally hardcoded and not configurable: once any user + // exists this route returns 403 unconditionally, so tuning the limit provides + // no operational value (Issue #1970, AC6). fastify.post( '/setup', { schema: setupSchema, config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } }, @@ -136,7 +139,15 @@ export default async function authRoutes(fastify: FastifyInstance) { */ fastify.post( '/login', - { schema: loginSchema, config: { rateLimit: { max: 20, timeWindow: '15 minutes' } } }, + { + schema: loginSchema, + config: { + rateLimit: { + max: fastify.config.authRateLimitMax, + timeWindow: fastify.config.authRateLimitWindow, + }, + }, + }, async (request, reply) => { const { email, password } = request.body as { email: string; diff --git a/server/src/services/backupService.test.ts b/server/src/services/backupService.test.ts index c0f089db2..bb2131a32 100644 --- a/server/src/services/backupService.test.ts +++ b/server/src/services/backupService.test.ts @@ -69,6 +69,8 @@ const makeConfig = (overrides: Partial = {}): AppConfig => ({ llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }); diff --git a/server/src/services/budgetExtraction/index.test.ts b/server/src/services/budgetExtraction/index.test.ts index 71eb9cff4..d8c55f9ed 100644 --- a/server/src/services/budgetExtraction/index.test.ts +++ b/server/src/services/budgetExtraction/index.test.ts @@ -44,6 +44,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }; } diff --git a/server/src/services/draftCleanupService.test.ts b/server/src/services/draftCleanupService.test.ts index 0bca5cd84..35ce9739d 100644 --- a/server/src/services/draftCleanupService.test.ts +++ b/server/src/services/draftCleanupService.test.ts @@ -84,6 +84,8 @@ const makeConfig = (overrides: Partial = {}): AppConfig => ({ llmProvider: 'generic', autoItemizeEnabled: false, llmEnabled: false, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }); diff --git a/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts b/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts index c7a300bf9..fe78ce360 100644 --- a/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts +++ b/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts @@ -66,6 +66,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { llmProvider: 'openai', autoItemizeEnabled: true, llmEnabled: true, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }; } diff --git a/server/src/services/invoiceAutoItemizeService.patch.test.ts b/server/src/services/invoiceAutoItemizeService.patch.test.ts index d105e00bd..859d55547 100644 --- a/server/src/services/invoiceAutoItemizeService.patch.test.ts +++ b/server/src/services/invoiceAutoItemizeService.patch.test.ts @@ -82,6 +82,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { llmProvider: 'openai', autoItemizeEnabled: true, llmEnabled: true, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }; } diff --git a/server/src/services/invoiceAutoItemizeService.test.ts b/server/src/services/invoiceAutoItemizeService.test.ts index 7f78a4308..bfc819de3 100644 --- a/server/src/services/invoiceAutoItemizeService.test.ts +++ b/server/src/services/invoiceAutoItemizeService.test.ts @@ -172,6 +172,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { llmProvider: 'openai', autoItemizeEnabled: true, llmEnabled: true, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }; } diff --git a/server/src/services/reportContentGenerationService.test.ts b/server/src/services/reportContentGenerationService.test.ts index 5545186fd..62d77ee72 100644 --- a/server/src/services/reportContentGenerationService.test.ts +++ b/server/src/services/reportContentGenerationService.test.ts @@ -103,6 +103,8 @@ function makeConfig(overrides: Partial = {}): AppConfig { llmProvider: 'openai', autoItemizeEnabled: true, llmEnabled: true, + authRateLimitMax: 20, + authRateLimitWindow: '15 minutes', ...overrides, }; } diff --git a/wiki b/wiki index 076816ea4..5c1c7e71a 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 076816ea472b5fdf4f9555c8575aed04211b6324 +Subproject commit 5c1c7e71a6981116147c5c1b3b365cf491d1c353 From 679e19ca7eacbc7a149222e2b76065a132c48ec3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:08:13 +0200 Subject: [PATCH 12/42] chore(deps): bump the github-actions group across 1 directory with 3 updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediates Dependabot bump of github-actions group (docker/login-action 4.5.1→4.6.0, docker/scout-action 1.23.1→1.24.0, github/codeql-action/upload-sarif patch). Security-hardening and bugfix releases only — no breaking changes, no new permissions. See PR description for full changelog. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda096133..5a0371adf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -242,7 +242,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to DHI registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: dhi.io username: ${{ vars.DOCKERHUB_USERNAME }} @@ -405,7 +405,7 @@ jobs: run: docker load -i cornerstone-e2e.tar - name: Login to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e92b95e41..4f47c3037 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,14 +153,14 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to DHI registry - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: dhi.io username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -186,7 +186,7 @@ jobs: - name: Docker Scout compare to latest if: matrix.platform == 'linux/amd64' && github.actor == 'steilerdev' - uses: docker/scout-action@2688993af7bafd6ba8c6a74ec652442be91dd82b # v1.23.1 + uses: docker/scout-action@7c6b6c3f7844478ace1ffd4e7aef649053d1f87d # v1.24.0 with: command: compare image: registry://steilerdev/cornerstone@${{ steps.build.outputs.digest }} @@ -230,7 +230,7 @@ jobs: uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Login to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -308,13 +308,13 @@ jobs: steps: - name: Login to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Docker Scout CVE scan - uses: docker/scout-action@2688993af7bafd6ba8c6a74ec652442be91dd82b # v1.23.1 + uses: docker/scout-action@7c6b6c3f7844478ace1ffd4e7aef649053d1f87d # v1.24.0 with: command: cves image: steilerdev/cornerstone:${{ needs.release.outputs.new-release-version }} @@ -322,7 +322,7 @@ jobs: summary: true - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 if: always() with: sarif_file: scout-results.sarif @@ -427,7 +427,7 @@ jobs: ref: v${{ needs.release.outputs.new-release-version }} - name: Login to Docker Hub - uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} From 56421afe5fafff75391c638a9c7de4b457b164ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:08:25 +0200 Subject: [PATCH 13/42] chore(deps): bump the prod-dependencies group across 1 directory with 4 updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediates Dependabot bump of prod-dependencies group: @fastify/rate-limit 11.2.0 (security release GHSA-grpc-p53c-r64v), better-sqlite3 13.0.2 (SQLite engine 3.53.4 segfault fix), fastify 5.11.0 (Content-Type parsing fix, async hook hardening), react-router-dom 7.18.2 (patch). Note: the @fastify/rate-limit bump is necessary but does not fully close CVE-2026-15144 for this codebase — a custom keyGenerator in rateLimitPlugin.ts bypasses IPv6 normalization. Remediation tracked in #1995. --- client/package.json | 2 +- package-lock.json | 51 ++++++++++++++++++++++++++------------------- server/package.json | 6 +++--- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/client/package.json b/client/package.json index 0ee50d3fa..099adb079 100644 --- a/client/package.json +++ b/client/package.json @@ -20,7 +20,7 @@ "react-dom": "19.2.8", "react-i18next": "17.0.11", "react-konva": "19.2.5", - "react-router-dom": "7.18.1" + "react-router-dom": "7.18.2" }, "devDependencies": { "@babel/core": "7.29.7", diff --git a/package-lock.json b/package-lock.json index 9b3999990..f16b3b974 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "react-dom": "19.2.8", "react-i18next": "17.0.11", "react-konva": "19.2.5", - "react-router-dom": "7.18.1" + "react-router-dom": "^7.18.2" }, "devDependencies": { "@babel/core": "7.29.7", @@ -5948,9 +5948,9 @@ } }, "node_modules/@fastify/rate-limit": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.1.0.tgz", - "integrity": "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz", + "integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==", "funding": [ { "type": "github", @@ -5965,6 +5965,7 @@ "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^6.0.0", + "ip-address": "^10.2.0", "toad-cache": "^3.7.0" } }, @@ -12867,10 +12868,9 @@ "license": "Apache-2.0" }, "node_modules/better-sqlite3": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz", - "integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -17842,9 +17842,9 @@ } }, "node_modules/fastify": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", - "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.11.0.tgz", + "integrity": "sha512-Y/Ecx1yt0hYzrQR+QVLbxaUN8wCX+MQzMevh29r5RRvlOIArmi4+WAXXaGl5Hw5gBr2wduZpZaT3gRCnXoyVgA==", "funding": [ { "type": "github", @@ -19996,6 +19996,15 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -31067,12 +31076,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.18.1" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -31083,9 +31092,9 @@ } }, "node_modules/react-router-dom/node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -37182,11 +37191,11 @@ "@fastify/cookie": "11.1.2", "@fastify/helmet": "13.1.0", "@fastify/multipart": "10.1.0", - "@fastify/rate-limit": "11.1.0", + "@fastify/rate-limit": "11.2.0", "@fastify/static": "10.1.2", - "better-sqlite3": "13.0.1", + "better-sqlite3": "13.0.2", "drizzle-orm": "0.45.2", - "fastify": "5.10.0", + "fastify": "5.11.0", "fastify-plugin": "6.0.0", "ical-generator": "11.1.0", "node-cron": "4.6.0", diff --git a/server/package.json b/server/package.json index 1b4caba0d..0c1c37e23 100644 --- a/server/package.json +++ b/server/package.json @@ -17,11 +17,11 @@ "@fastify/cookie": "11.1.2", "@fastify/helmet": "13.1.0", "@fastify/multipart": "10.1.0", - "@fastify/rate-limit": "11.1.0", + "@fastify/rate-limit": "11.2.0", "@fastify/static": "10.1.2", - "better-sqlite3": "13.0.1", + "better-sqlite3": "13.0.2", "drizzle-orm": "0.45.2", - "fastify": "5.10.0", + "fastify": "5.11.0", "fastify-plugin": "6.0.0", "ical-generator": "11.1.0", "node-cron": "4.6.0", From ef1346206a7f451e6dd6b6ed714e06559a59cf85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:33:59 +0200 Subject: [PATCH 14/42] chore(deps): bump dev-dependencies and dedupe webpack lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remediates webpack dual-instance crash (5.108.4 + 5.109.2 coexisting → single 5.109.2) - Dev-dependency bumps: prettier, @types/*, concurrently, ts-jest, typescript-eslint - `npm dedupe` fixes lockfile nesting from Dependabot's `--package-lock-only` Fixes #1976-related webpack TypeError Co-Authored-By: Claude frontend-developer --- client/package.json | 12 +- docs/package.json | 4 +- e2e/package.json | 4 +- package-lock.json | 2246 ++++++++++++++++--------------------------- package.json | 18 +- 5 files changed, 855 insertions(+), 1429 deletions(-) diff --git a/client/package.json b/client/package.json index 099adb079..f6393e2ad 100644 --- a/client/package.json +++ b/client/package.json @@ -27,17 +27,17 @@ "@babel/preset-react": "7.29.7", "@babel/preset-typescript": "7.29.7", "@types/pdfmake": "0.3.3", - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", "babel-loader": "10.1.1", "copy-webpack-plugin": "14.0.0", "css-loader": "7.1.4", "css-minimizer-webpack-plugin": "8.0.0", - "html-webpack-plugin": "5.6.7", + "html-webpack-plugin": "5.6.8", "mini-css-extract-plugin": "2.10.2", "style-loader": "4.0.0", - "webpack": "5.108.4", - "webpack-cli": "7.1.0", - "webpack-dev-server": "5.2.6" + "webpack": "5.109.2", + "webpack-cli": "7.2.2", + "webpack-dev-server": "6.0.0" } } diff --git a/docs/package.json b/docs/package.json index 3dcd6ecfa..b4c0614f1 100644 --- a/docs/package.json +++ b/docs/package.json @@ -9,8 +9,8 @@ "clear": "docusaurus clear" }, "devDependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/preset-classic": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", "@mdx-js/react": "3.1.1", "prism-react-renderer": "2.4.1", "react": "19.2.8", diff --git a/e2e/package.json b/e2e/package.json index 7c0d443d9..1ed888cde 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,8 +12,8 @@ "screenshots": "npx playwright test tests/screenshots/ --project=desktop" }, "devDependencies": { - "@playwright/test": "1.61.1", - "@types/node": "26.1.0", + "@playwright/test": "1.62.1", + "@types/node": "26.1.2", "testcontainers": "12.0.4" } } diff --git a/package-lock.json b/package-lock.json index f16b3b974..00ad3cd73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,26 +15,26 @@ "docs" ], "devDependencies": { - "@eslint-react/eslint-plugin": "5.10.4", + "@eslint-react/eslint-plugin": "5.18.1", "@eslint/js": "10.0.1", - "@testing-library/jest-dom": "6.9.1", + "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@types/jest": "30.0.0", "concurrently": "10.0.4", - "conventional-changelog-conventionalcommits": "10.2.0", - "eslint": "10.6.0", + "conventional-changelog-conventionalcommits": "10.2.1", + "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "identity-obj-proxy": "3.0.0", "jest": "30.4.2", "jest-environment-jsdom": "30.4.1", - "prettier": "3.9.4", - "semantic-release": "25.0.5", - "stylelint": "17.14.0", + "prettier": "3.9.6", + "semantic-release": "25.0.8", + "stylelint": "17.14.1", "stylelint-config-standard": "40.0.0", - "ts-jest": "29.4.11", + "ts-jest": "29.4.12", "typescript": "6.0.3", - "typescript-eslint": "8.62.1" + "typescript-eslint": "8.65.0" }, "engines": { "node": ">=24.0.0" @@ -61,26 +61,26 @@ "@babel/preset-react": "7.29.7", "@babel/preset-typescript": "7.29.7", "@types/pdfmake": "0.3.3", - "@types/react": "19.2.17", - "@types/react-dom": "19.2.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", "babel-loader": "10.1.1", "copy-webpack-plugin": "14.0.0", "css-loader": "7.1.4", "css-minimizer-webpack-plugin": "8.0.0", - "html-webpack-plugin": "5.6.7", + "html-webpack-plugin": "5.6.8", "mini-css-extract-plugin": "2.10.2", "style-loader": "4.0.0", - "webpack": "5.108.4", - "webpack-cli": "7.1.0", - "webpack-dev-server": "5.2.6" + "webpack": "5.109.2", + "webpack-cli": "7.2.2", + "webpack-dev-server": "6.0.0" } }, "docs": { "name": "@cornerstone/docs", "version": "0.1.0", "devDependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/preset-classic": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", "@mdx-js/react": "3.1.1", "prism-react-renderer": "2.4.1", "react": "19.2.8", @@ -91,11 +91,27 @@ "name": "@cornerstone/e2e", "version": "0.1.0", "devDependencies": { - "@playwright/test": "1.61.1", - "@types/node": "26.1.0", + "@playwright/test": "1.62.1", + "@types/node": "26.1.2", "testcontainers": "12.0.4" } }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@11ty/gray-matter/-/gray-matter-1.0.0.tgz", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", @@ -143,49 +159,49 @@ "license": "MIT" }, "node_modules/@algolia/abtesting": { - "version": "1.21.2", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.21.2.tgz", - "integrity": "sha512-uXj0rgk30EpsKvOpuS+R+1XFDrnm56hED1Lz56e8uBkZdKCxw99LS2U8eXBqAHYU8kpkbsnV1GC8velBG070Hg==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/autocomplete-core": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.9.tgz", - "integrity": "sha512-4U2JKLMWlDu0CotYyUkWakDxr8AIav3QtIUXXRpfavYN29aVWfzlwJp9T0rPKEf/dO2QCPAUc0Kq1Tj1GJxo2A==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.9", - "@algolia/autocomplete-shared": "1.19.9" + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" } }, "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.9.tgz", - "integrity": "sha512-6mExC6X7762s2SV3eJy3QOkB8bdMmnUhQ2agvGVDuzwoGyr3PquGSY/0vPQXCfiAiCaXUz1rXn+lwghgSi0l0w==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/autocomplete-shared": "1.19.9" + "@algolia/autocomplete-shared": "1.19.2" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "node_modules/@algolia/autocomplete-shared": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.9.tgz", - "integrity": "sha512-YosP9Uoek6y/Ur1r1qeogk4biMe/hzkyNcgMCciw0//3XpCM7VlYLSHnyt/vOnEOGhCCc0+3v+unEiH6zz+Z1A==", + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -194,41 +210,41 @@ } }, "node_modules/@algolia/client-abtesting": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.55.2.tgz", - "integrity": "sha512-y7Epol8HcjlBxKXHhyhfFPFhm78B3P6x9cCbCyGTdxjsdVCptXCy5hpkZWxjGpnaLHvWsHS4QRF0TiBOLst2xg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-analytics": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.55.2.tgz", - "integrity": "sha512-8Pxj2VVmpM2d+UZufnlTq7T1QIcYPVugLV5XC50PnHsV5uRM9CSoYkg2Y+CwqwRk2La0xK5QsfZ0obIU+9XftQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-common": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.55.2.tgz", - "integrity": "sha512-9L4IpIYUqA63a7sw1trnHQGUvwiAjKz67nsgDnal98JGAc7wyposRb0Iag+eiMuyzFFaSHLe2/rGyIo+PafRBA==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", "dev": true, "license": "MIT", "engines": { @@ -236,64 +252,64 @@ } }, "node_modules/@algolia/client-insights": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.55.2.tgz", - "integrity": "sha512-ZBm2ytY5EHFcj+kjNsXxMNO/TGlOHe2fBFXGKHJOM1bk1rAy4o2YI+d9oV/w/jrqx44pvJMJlc8X6vKnCuDgUQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-personalization": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.55.2.tgz", - "integrity": "sha512-3FGVW/jDk7sdYwqa2NKnF/qXWcttc4bvGrwNbvqz3VoWSRv42CNvRk+3Y9QJFIUf1vY50hAuVWUoFKdyc8vaXA==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-query-suggestions": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.55.2.tgz", - "integrity": "sha512-JsG8LovDAYul5t8e533tZ3O1uZILxso5zsTtB7ONc5RJ8ACdTxAAC/jaOnsBNYb+x+STP7fzx/Iro55v5DNgoQ==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.55.2.tgz", - "integrity": "sha512-5wDnoIfC75zJ2MSHv5SSzTlRL2z7jQMbqQ5jrzottuq2p3oBObv8pD/JpXWu8pRaimaxNr3/Bs/KZIGVXxJ7hg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" @@ -307,87 +323,87 @@ "license": "MIT" }, "node_modules/@algolia/ingestion": { - "version": "1.55.2", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.55.2.tgz", - "integrity": "sha512-da+SC6ikpza98W7C5ChsKEQDvZc8PQLQ0sxmQ5yMRsHpdD3iPKnclJA6ViB5Nr5T9qOX+IDswC6AyqY4V3rtug==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/monitoring": { - "version": "1.55.2", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.55.2.tgz", - "integrity": "sha512-Y8kEcPqCiIEeaGv83l9RRA09mfYECqAJHNnOyEtZc9UirI6XBMUyFVss/sSeYUiV/Lf30hkbWcl00V1uXsf86Q==", + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/recommend": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.55.2.tgz", - "integrity": "sha512-5zmobuCQqFZkx+84Nt+suL7vo6jTh2CfAs2ndDSeTS2QHvnzP8YEEGWtWftjyACI0cK/FuH8urWwCHP+d2j8TA==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.55.2.tgz", - "integrity": "sha512-qnGUUuWG66dRMnr33owLsrYIh9fHVxtU4R2rd3SpneAHuoAUcGbDOWNrj05glVU6M8yOqo9gQ22K8zpz0I8Xpg==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-fetch": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.55.2.tgz", - "integrity": "sha512-lKZ5uhafMvR7dWCJEyuaeyZitid1I3ICx+k0vGf5x/ktdIQvc7bndCiOPpmIDqUmN26FE3jTehkAzSqee95G2Q==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" } }, "node_modules/@algolia/requester-node-http": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.55.2.tgz", - "integrity": "sha512-Zc90xvKWUvxcNicvvTO9Pr/hT2TAnkixOIzJm/KMj5Ptm2pKjk71ngTsdkbRtJQvhZ2Kr9N1YdIjLrNHB5P2xw==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/client-common": "5.55.2" + "@algolia/client-common": "5.56.0" }, "engines": { "node": ">= 14.0.0" @@ -481,14 +497,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -803,13 +819,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1639,16 +1655,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", - "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -1962,9 +1978,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", - "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", "dev": true, "license": "MIT", "dependencies": { @@ -2058,9 +2074,9 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", - "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", + "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==", "dev": true, "license": "MIT", "dependencies": { @@ -2400,18 +2416,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -2419,9 +2435,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -3885,9 +3901,9 @@ } }, "node_modules/@csstools/selector-resolve-nested": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.0.tgz", - "integrity": "sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-resolve-nested/-/selector-resolve-nested-4.0.1.tgz", + "integrity": "sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==", "dev": true, "funding": [ { @@ -3964,9 +3980,9 @@ } }, "node_modules/@docsearch/core": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.3.tgz", - "integrity": "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.7.0.tgz", + "integrity": "sha512-p/9xVKmPDj3FPvMfPf5naVO3Ej8SCbcUugGvx1+8GgkuBNbqxqN2Irx3WLBv8VY0jH7XpRwKWdlmjXLZsmTLsg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3987,22 +4003,22 @@ } }, "node_modules/@docsearch/css": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz", - "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.0.tgz", + "integrity": "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==", "dev": true, "license": "MIT" }, "node_modules/@docsearch/react": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.3.tgz", - "integrity": "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.7.0.tgz", + "integrity": "sha512-x6oedjJ8O8/pIDBsMo5Orca3/6cQCz616/CwthVe68l43mqnj2lrJ9kFQITBqy8hMsS3nWeBWFoVO5dJ1DCFKA==", "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "1.19.2", - "@docsearch/core": "4.6.3", - "@docsearch/css": "4.6.3" + "@docsearch/core": "4.7.0", + "@docsearch/css": "4.7.0" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 20.0.0", @@ -4025,45 +4041,10 @@ } } }, - "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-core": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.19.2.tgz", - "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", - "@algolia/autocomplete-shared": "1.19.2" - } - }, - "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.19.2.tgz", - "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.19.2" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-shared": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.19.2.tgz", - "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, "node_modules/@docusaurus/babel": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz", - "integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.2.tgz", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4076,8 +4057,8 @@ "@babel/preset-typescript": "^7.25.9", "@babel/runtime": "^7.25.9", "@babel/traverse": "^7.25.9", - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-plugin-dynamic-import-node": "^2.3.3", "fs-extra": "^11.1.1", "tslib": "^2.6.0" @@ -4087,18 +4068,18 @@ } }, "node_modules/@docusaurus/bundler": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz", - "integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.2.tgz", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.25.9", - "@docusaurus/babel": "3.10.1", - "@docusaurus/cssnano-preset": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "babel-loader": "^9.2.1", "clean-css": "^5.3.3", "copy-webpack-plugin": "^11.0.0", @@ -4162,9 +4143,9 @@ } }, "node_modules/@docusaurus/bundler/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "dev": true, "license": "MIT" }, @@ -4451,19 +4432,19 @@ } }, "node_modules/@docusaurus/core": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz", - "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.2.tgz", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/babel": "3.10.1", - "@docusaurus/bundler": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "boxen": "^6.2.1", "chalk": "^4.1.2", "chokidar": "^3.5.3", @@ -4471,7 +4452,7 @@ "combine-promises": "^1.1.0", "commander": "^5.1.0", "core-js": "^3.31.1", - "detect-port": "^1.5.1", + "detect-port": "^2.1.0", "escape-html": "^1.0.3", "eta": "^2.2.0", "eval": "^0.1.8", @@ -4637,9 +4618,9 @@ } }, "node_modules/@docusaurus/cssnano-preset": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz", - "integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.2.tgz", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", "dev": true, "license": "MIT", "dependencies": { @@ -4653,9 +4634,9 @@ } }, "node_modules/@docusaurus/logger": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz", - "integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.2.tgz", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", "dev": true, "license": "MIT", "dependencies": { @@ -4713,15 +4694,15 @@ } }, "node_modules/@docusaurus/mdx-loader": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz", - "integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.2.tgz", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/mdx": "^3.0.0", "@slorber/remark-comment": "^1.0.0", "escape-html": "^1.0.3", @@ -4753,13 +4734,13 @@ } }, "node_modules/@docusaurus/module-type-aliases": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz", - "integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.2.tgz", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -4773,20 +4754,20 @@ } }, "node_modules/@docusaurus/plugin-content-blog": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.1.tgz", - "integrity": "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "cheerio": "1.0.0-rc.12", "combine-promises": "^1.1.0", "feed": "^4.2.2", @@ -4809,21 +4790,21 @@ } }, "node_modules/@docusaurus/plugin-content-docs": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz", - "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.2.tgz", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@types/react-router-config": "^5.0.7", "combine-promises": "^1.1.0", "fs-extra": "^11.1.1", @@ -4843,17 +4824,17 @@ } }, "node_modules/@docusaurus/plugin-content-pages": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.1.tgz", - "integrity": "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.2.tgz", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "tslib": "^2.6.0", "webpack": "^5.88.1" @@ -4867,16 +4848,16 @@ } }, "node_modules/@docusaurus/plugin-css-cascade-layers": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.1.tgz", - "integrity": "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.2.tgz", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4884,15 +4865,15 @@ } }, "node_modules/@docusaurus/plugin-debug": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.1.tgz", - "integrity": "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.2.tgz", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", "fs-extra": "^11.1.1", "react-json-view-lite": "^2.3.0", "tslib": "^2.6.0" @@ -4906,15 +4887,15 @@ } }, "node_modules/@docusaurus/plugin-google-analytics": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.1.tgz", - "integrity": "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.2.tgz", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4926,16 +4907,15 @@ } }, "node_modules/@docusaurus/plugin-google-gtag": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.1.tgz", - "integrity": "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.2.tgz", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", - "@types/gtag.js": "^0.0.20", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4947,15 +4927,15 @@ } }, "node_modules/@docusaurus/plugin-google-tag-manager": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.1.tgz", - "integrity": "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.2.tgz", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -4967,18 +4947,18 @@ } }, "node_modules/@docusaurus/plugin-sitemap": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.1.tgz", - "integrity": "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.2.tgz", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "fs-extra": "^11.1.1", "sitemap": "^7.1.1", "tslib": "^2.6.0" @@ -4992,16 +4972,16 @@ } }, "node_modules/@docusaurus/plugin-svgr": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.1.tgz", - "integrity": "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.2.tgz", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@svgr/core": "8.1.0", "@svgr/webpack": "^8.1.0", "tslib": "^2.6.0", @@ -5016,27 +4996,27 @@ } }, "node_modules/@docusaurus/preset-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.1.tgz", - "integrity": "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.2.tgz", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/plugin-css-cascade-layers": "3.10.1", - "@docusaurus/plugin-debug": "3.10.1", - "@docusaurus/plugin-google-analytics": "3.10.1", - "@docusaurus/plugin-google-gtag": "3.10.1", - "@docusaurus/plugin-google-tag-manager": "3.10.1", - "@docusaurus/plugin-sitemap": "3.10.1", - "@docusaurus/plugin-svgr": "3.10.1", - "@docusaurus/theme-classic": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-search-algolia": "3.10.1", - "@docusaurus/types": "3.10.1" + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" }, "engines": { "node": ">=20.0" @@ -5047,25 +5027,25 @@ } }, "node_modules/@docusaurus/theme-classic": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.1.tgz", - "integrity": "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/plugin-content-blog": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/plugin-content-pages": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.2.tgz", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", "copy-text-to-clipboard": "^3.2.0", @@ -5108,16 +5088,16 @@ } }, "node_modules/@docusaurus/theme-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz", - "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.2.tgz", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/mdx-loader": "3.10.1", - "@docusaurus/module-type-aliases": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router-config": "*", @@ -5137,21 +5117,21 @@ } }, "node_modules/@docusaurus/theme-search-algolia": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.1.tgz", - "integrity": "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.2.tgz", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", "dev": true, "license": "MIT", "dependencies": { "@algolia/autocomplete-core": "^1.19.2", "@docsearch/react": "^3.9.0 || ^4.3.2", - "@docusaurus/core": "3.10.1", - "@docusaurus/logger": "3.10.1", - "@docusaurus/plugin-content-docs": "3.10.1", - "@docusaurus/theme-common": "3.10.1", - "@docusaurus/theme-translations": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-validation": "3.10.1", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", "algoliasearch": "^5.37.0", "algoliasearch-helper": "^3.26.0", "clsx": "^2.0.0", @@ -5170,9 +5150,9 @@ } }, "node_modules/@docusaurus/theme-translations": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.1.tgz", - "integrity": "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.2.tgz", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", "dev": true, "license": "MIT", "dependencies": { @@ -5184,9 +5164,9 @@ } }, "node_modules/@docusaurus/types": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz", - "integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.2.tgz", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", "dev": true, "license": "MIT", "dependencies": { @@ -5222,22 +5202,22 @@ } }, "node_modules/@docusaurus/utils": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz", - "integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.2.tgz", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/types": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "escape-string-regexp": "^4.0.0", "execa": "^5.1.1", "file-loader": "^6.2.0", "fs-extra": "^11.1.1", "github-slugger": "^1.5.0", "globby": "^11.1.0", - "gray-matter": "^4.0.3", "jiti": "^1.20.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", @@ -5255,13 +5235,13 @@ } }, "node_modules/@docusaurus/utils-common": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz", - "integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.2.tgz", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/types": "3.10.1", + "@docusaurus/types": "3.10.2", "tslib": "^2.6.0" }, "engines": { @@ -5269,15 +5249,15 @@ } }, "node_modules/@docusaurus/utils-validation": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz", - "integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==", + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.2.tgz", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", "dev": true, "license": "MIT", "dependencies": { - "@docusaurus/logger": "3.10.1", - "@docusaurus/utils": "3.10.1", - "@docusaurus/utils-common": "3.10.1", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", "fs-extra": "^11.2.0", "joi": "^17.9.2", "js-yaml": "^4.1.0", @@ -5373,15 +5353,15 @@ } }, "node_modules/@eslint-react/ast": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-5.10.4.tgz", - "integrity": "sha512-FcxjL+TjzJeH+FnHRjahLEh5OccKIbPQwujxzribXMkkuHYOI44sLXtcOhslERiSU8DikOdnZzBNrhiujW9zwQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/ast/-/ast-5.18.1.tgz", + "integrity": "sha512-6NTpdv+Z6eHT2lc8DkfJZUG6tUvZQkRBlvBpYuoeen+HIvzz59aOt1YEL4dd6FQlXD4DPbCBGXCjcV879hT8Sg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/typescript-estree": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/typescript-estree": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "string-ts": "^2.3.1" }, "engines": { @@ -5393,20 +5373,19 @@ } }, "node_modules/@eslint-react/core": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-5.10.4.tgz", - "integrity": "sha512-bxBgTtAx0VbZSWUP+TKqw80Z2vBX9aQ0v5XDOvx4juIgy9gIfND4DqFo6CIJ4SkmH0ZdzqiCBwmhbETHgXumnQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/core/-/core-5.18.1.tgz", + "integrity": "sha512-n2avrghmy22iMFJWGXKacyMMvtaFrPqq1Yydd7L1DKmUe/hubjD4Uc1ZKV/F2xRh8dQ0Gp2b5TVv7H/Nq5DxUQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/jsx": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/scope-manager": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/scope-manager": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "ts-pattern": "^5.9.0" }, "engines": { @@ -5418,13 +5397,13 @@ } }, "node_modules/@eslint-react/eslint": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/eslint/-/eslint-5.10.4.tgz", - "integrity": "sha512-/PHUvYVpRZRvWVT15HySdCySxZ2d6KmlBV4VomhmQocvb6n/haRJ4SVTqSy0I+CV327TBFnaHw1hUdMw7zROxw==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint/-/eslint-5.18.1.tgz", + "integrity": "sha512-T7AOWRwc1+gBEQUFoGSsBVHmWpsanGWVU52NTmWxij9ZGIlqbG+JC6vpCBa6IzGPKbulCu4B/np2JIydZQM1pQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.62.1" + "@typescript-eslint/utils": "^8.65.0" }, "engines": { "node": ">=22.0.0" @@ -5435,19 +5414,19 @@ } }, "node_modules/@eslint-react/eslint-plugin": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-5.10.4.tgz", - "integrity": "sha512-5+bJsrBkXHarZBrSk9DBwRtbWXwW8GmXt2fYCNrJrp2hWtN2nzd9vI+9dkgQV1Pr1Jth5MoEJq4YRGbTL8bsBw==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/eslint-plugin/-/eslint-plugin-5.18.1.tgz", + "integrity": "sha512-BlgYu//MKKnLHhQhh6E6ChSbEYj/a/cuRnyc+akOUtn8qCEqf2Le+/Rcxjfso3ujDjrq5yZt8E28aNOubtE6WA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/shared": "5.10.4", - "eslint-plugin-react-dom": "5.10.4", - "eslint-plugin-react-jsx": "5.10.4", - "eslint-plugin-react-naming-convention": "5.10.4", - "eslint-plugin-react-rsc": "5.10.4", - "eslint-plugin-react-web-api": "5.10.4", - "eslint-plugin-react-x": "5.10.4" + "@eslint-react/shared": "5.18.1", + "eslint-plugin-react-dom": "5.18.1", + "eslint-plugin-react-jsx": "5.18.1", + "eslint-plugin-react-naming-convention": "5.18.1", + "eslint-plugin-react-rsc": "5.18.1", + "eslint-plugin-react-web-api": "5.18.1", + "eslint-plugin-react-x": "5.18.1" }, "engines": { "node": ">=22.0.0" @@ -5458,18 +5437,18 @@ } }, "node_modules/@eslint-react/jsx": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-5.10.4.tgz", - "integrity": "sha512-XK374cpduryK3+/zDCWegF+c1ilLF8T0a1lCC5J9Z/56uGf7n/bEASyTyBLsnkJlxX/8+b9sYXT+lqnOAAyoNg==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/jsx/-/jsx-5.18.1.tgz", + "integrity": "sha512-sT0JQ0yw/prFwtEu0cplzS0fZBpNHL1s7awHrD5xKABpqsVS2wofB52p2TSX2nmuNj011hSUW+xlu58GXYZ/fQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "ts-pattern": "^5.9.0" }, "engines": { @@ -5481,14 +5460,14 @@ } }, "node_modules/@eslint-react/shared": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-5.10.4.tgz", - "integrity": "sha512-gPWtJ4IYAwa3rAyRBh0Lkw6dWXSPLCzlQVttMyb4JFFcIYSnlPkiHv/MIpa24F8Mp40AapqOaX57JXJSgZ3eSQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/shared/-/shared-5.18.1.tgz", + "integrity": "sha512-sLtaIw5PGVriltlBfDD/wUvF4+K3EDGuFhoUF2HKSIdhf28glyrcf6s2d0HnjFXUjXKcD325MNWL7Cectvs2GA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/eslint": "5.10.4", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/eslint": "5.18.1", + "@typescript-eslint/utils": "^8.65.0", "ts-pattern": "^5.9.0", "zod": "^3.25.0 || ^4.0.0" }, @@ -5501,17 +5480,17 @@ } }, "node_modules/@eslint-react/var": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-5.10.4.tgz", - "integrity": "sha512-fXqXn6tlvp/eJFDuqhWilaMifwLDcY1fMT9/q5PZJb7N9Row7oJ6tHy9zPTBhle0jAU+QprhK0irHGTlUqlN8Q==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/@eslint-react/var/-/var-5.18.1.tgz", + "integrity": "sha512-w1OP5/EJ4ocGWSw+nSpZnfehlnAvfVxOrlgx7k+xTHACyoFQZZXrehOYC7M/0ArQA68sOXo0C1ymEfTJE30StQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@typescript-eslint/scope-manager": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@typescript-eslint/scope-manager": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "ts-pattern": "^5.9.0" }, "engines": { @@ -5538,9 +5517,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -6938,9 +6917,9 @@ } }, "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -7921,14 +7900,14 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.63.0.tgz", - "integrity": "sha512-sYl1G4mU/Ayy5pPu8Rp22h+DFRwvYLUYvKgPxmaoc8SsQJN/LsLUT9L7eGvSVDDZ77+xih8yVUYr8Gz4irvhcw==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.67.0.tgz", + "integrity": "sha512-+QOYAGujzm86pKcX4N0JQ1YcLEjypr/I+wmQRxwI8W7K0QXKSi8vQVC2oKQGjcfbHq02JvCQijypfvyhcTz7uw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.63.0", - "@jsonjoy.com/fs-node-utils": "4.63.0", + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", "thingies": "^2.5.0" }, "engines": { @@ -7943,15 +7922,15 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.63.0.tgz", - "integrity": "sha512-Mapa4H9o0tFX+MUaqRvCxracqjDUqMsHv12NYDH4Jcw+cfmwUQfuHTgGCm6p8dChk88eL7mhYbL3vvElH0W2xA==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.67.0.tgz", + "integrity": "sha512-2jCnH5ofKXb+6vcl8dQArO1Gb4FT7vLbMGVnNim0ekXkY78DPVXZvJ8DQp9WLbqP/G/gxiPVz/DOMoOCD4BqqQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.63.0", - "@jsonjoy.com/fs-node-builtins": "4.63.0", - "@jsonjoy.com/fs-node-utils": "4.63.0", + "@jsonjoy.com/fs-core": "4.67.0", + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", "thingies": "^2.5.0" }, "engines": { @@ -7966,17 +7945,17 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.63.0.tgz", - "integrity": "sha512-y4/0oO1BEGXmM2TtYuJzgI2RRJE1JTZP/BlpDC0jQtUR0Jxx/zk//HlltW58iu+LXrr+zX/ndDrhuwN0HVJbGQ==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.67.0.tgz", + "integrity": "sha512-EZ/mSrxRYphDbyll1VuDW0mvj/USoe2M5sxT2nYqyYyvdxsIsijhJOiygBHAaj86Eqd/Kb9ukkwXjBisRTk1tg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.63.0", - "@jsonjoy.com/fs-node-builtins": "4.63.0", - "@jsonjoy.com/fs-node-utils": "4.63.0", - "@jsonjoy.com/fs-print": "4.63.0", - "@jsonjoy.com/fs-snapshot": "4.63.0", + "@jsonjoy.com/fs-core": "4.67.0", + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", + "@jsonjoy.com/fs-print": "4.67.0", + "@jsonjoy.com/fs-snapshot": "4.67.0", "glob-to-regex.js": "^1.0.0", "thingies": "^2.5.0" }, @@ -7992,9 +7971,9 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.63.0.tgz", - "integrity": "sha512-8vJl70rxZHMyEkhJk5PGaTjyHv1xzp0+neza2FLieU4oD9dP9Z8ddCUQQACxbQtfAMp5P1dyV2QkPwBHRZu1kQ==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.67.0.tgz", + "integrity": "sha512-os7Cft1EudH0xZs5Kh5/qHI72jk8DMQ1561elyHkHd9c9xaa4wOfK1iMh4JB9y+kXtpXjEnT4cjHqqc7A3X3lA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -8009,15 +7988,15 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.63.0.tgz", - "integrity": "sha512-UDzueqB8eRfYiYx7pmzXp/BjWX3ScvMGW0Lu+IJcP7HYIokTiUJJhqfMrnL8c7/lhzf6WDQQ6plUsCVitF/xYg==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.67.0.tgz", + "integrity": "sha512-5e6WTnLhw0Q5mPEACOsA7h2BA++N1FCSmhXRX5gone8Le4fsqcpgpukqW5hWneLfzIr5AOEliu0PyokdYx3Bwg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.63.0", - "@jsonjoy.com/fs-node-builtins": "4.63.0", - "@jsonjoy.com/fs-node-utils": "4.63.0" + "@jsonjoy.com/fs-fsa": "4.67.0", + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "@jsonjoy.com/fs-node-utils": "4.67.0" }, "engines": { "node": ">=10.0" @@ -8031,13 +8010,14 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.63.0.tgz", - "integrity": "sha512-e+DTkdbZEObGxfFt0lwzI3KEMQbYvDmh+iMzJLRXUPlggK3ru2LnzBEDBaCJuZT00k146zrKaCnobusFptb7EQ==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.67.0.tgz", + "integrity": "sha512-ZcCPh4jvUqYxAgu4lLe+6eQbijxEkZIrCq0Jhh669o3v3zrCn8N4YAFod3zllZtSHhwb+YbER29LUW/SdNrP7Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.63.0" + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "glob-to-regex.js": "^1.0.1" }, "engines": { "node": ">=10.0" @@ -8051,13 +8031,13 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.63.0.tgz", - "integrity": "sha512-IXGphCx99u965jFpA9JfFVO6svnwJISq8IyR4VkDTAmU6xxY6rSsxusJ9orpXrIGt3Rzwte5HUAYLJgjixVW6Q==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.67.0.tgz", + "integrity": "sha512-xBhay3ayVlFeScafZy+7jyH0+I6MLomaL+2nn/KWirgjwh58w8+u44j7oSvE8EQ2NLF63B1eVc65awmbs/39Iw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.63.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", "tree-dump": "^1.1.0" }, "engines": { @@ -8072,14 +8052,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.63.0.tgz", - "integrity": "sha512-8vMWlrp32sJersU7Zf+gRumbk81KfbfFuInfsk1nKL0g4nlK1Vhg3TXzhKMy8XnrCAZyToDRuYUuDq9P+L8tAA==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.67.0.tgz", + "integrity": "sha512-wn5c6Qx0iVX1dV74l5WOCIPH9lz3xU6A0QG45n0c53athmmr7Z+XTg0YOndME88g/5LjcqwiSSYD9aSr9+goYg==", "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.63.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -8855,19 +8835,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@pnpm/config.env-replace": { @@ -9431,9 +9411,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, "license": "MIT" }, @@ -9890,9 +9870,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "dev": true, "license": "MIT", "dependencies": { @@ -9904,9 +9884,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -10157,17 +10140,10 @@ "@types/send": "*" } }, - "node_modules/@types/gtag.js": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.20.tgz", - "integrity": "sha512-wwAbk3SA2QeU67unN7zPxjEHmPmlXwZXZvQEpbEUQuMCRGgKyE1m6XDuTUA9b6pCGb/GqJmdfMOY5LuDjJSbbg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "dev": true, "license": "MIT", "dependencies": { @@ -10317,9 +10293,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10376,18 +10352,18 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -10456,12 +10432,13 @@ } }, "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "dev": true, "license": "MIT", "dependencies": { + "@types/mime": "^1", "@types/node": "*" } }, @@ -10487,17 +10464,6 @@ "@types/send": "<1" } }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -10509,13 +10475,14 @@ } }, "node_modules/@types/ssh2": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", - "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "^18.11.18" + "@types/node": "*", + "@types/ssh2-streams": "*" } }, "node_modules/@types/ssh2-streams": { @@ -10594,17 +10561,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", - "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/type-utils": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -10617,194 +10584,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.1", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", - "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -10812,16 +10600,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", - "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -10836,145 +10624,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@typescript-eslint/project-service": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", - "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.63.0", - "@typescript-eslint/types": "^8.63.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -10989,14 +10647,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", - "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11007,9 +10665,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", - "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -11024,15 +10682,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", - "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0", - "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -11049,9 +10707,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", - "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -11063,16 +10721,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", - "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.63.0", - "@typescript-eslint/tsconfig-utils": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/visitor-keys": "8.63.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -11091,16 +10749,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", - "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.63.0", - "@typescript-eslint/types": "8.63.0", - "@typescript-eslint/typescript-estree": "8.63.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11115,13 +10773,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.63.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", - "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11748,19 +11406,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -11785,13 +11430,13 @@ } }, "node_modules/address": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", - "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/address/-/address-2.0.3.tgz", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 16.0.0" } }, "node_modules/agent-base": { @@ -11901,26 +11546,26 @@ } }, "node_modules/algoliasearch": { - "version": "5.55.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.55.2.tgz", - "integrity": "sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==", + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", "dev": true, "license": "MIT", "dependencies": { - "@algolia/abtesting": "1.21.2", - "@algolia/client-abtesting": "5.55.2", - "@algolia/client-analytics": "5.55.2", - "@algolia/client-common": "5.55.2", - "@algolia/client-insights": "5.55.2", - "@algolia/client-personalization": "5.55.2", - "@algolia/client-query-suggestions": "5.55.2", - "@algolia/client-search": "5.55.2", - "@algolia/ingestion": "1.55.2", - "@algolia/monitoring": "1.55.2", - "@algolia/recommend": "5.55.2", - "@algolia/requester-browser-xhr": "5.55.2", - "@algolia/requester-fetch": "5.55.2", - "@algolia/requester-node-http": "5.55.2" + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" }, "engines": { "node": ">= 14.0.0" @@ -12401,9 +12046,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.2", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", - "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "dev": true, "funding": [ { @@ -12421,8 +12066,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.4", - "caniuse-lite": "^1.0.30001799", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -12831,9 +12476,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -12903,9 +12548,9 @@ } }, "node_modules/birecord": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.1.tgz", - "integrity": "sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/birecord/-/birecord-0.1.2.tgz", + "integrity": "sha512-5PAPTTmMpMEb+GuMb5DebfBkipRGyIW9+gtwEBSoDA9xkhHILm04+hZQ702pMksu3d8YAuGkmgTzQWcKqTPScA==", "dev": true, "license": "(MIT OR Apache-2.0)" }, @@ -13132,9 +12777,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -13175,9 +12820,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", - "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -13195,10 +12840,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001800", - "electron-to-chromium": "^1.5.387", - "node-releases": "^2.0.50", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -13503,9 +13148,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001803", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", - "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -14577,13 +14222,13 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.0.tgz", - "integrity": "sha512-UtlM9GqolY7OmlQh5L/UEVoKsTUpTgUVy1PU8JN5gl5Ydaejb7WRklGliG1SKPxxj7hzA173eG3Kt5fYWE2pmg==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.2.1.tgz", + "integrity": "sha512-n4Kr1HFMTf3iMbES0TMxKIcYtUUv4rKqyQQp2JwfOEfFCOfGT3Tq4mCyJ8S9/YPyWhydjfKrrvnyl+gCjA+mJQ==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.2.0" + "@conventional-changelog/template": "^1.2.1" }, "engines": { "node": ">=22" @@ -16206,21 +15851,20 @@ "license": "MIT" }, "node_modules/detect-port": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", - "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-2.1.0.tgz", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", "dev": true, "license": "MIT", "dependencies": { - "address": "^1.0.1", - "debug": "4" + "address": "^2.0.1" }, "bin": { - "detect": "bin/detect-port.js", - "detect-port": "bin/detect-port.js" + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" }, "engines": { - "node": ">= 4.0.0" + "node": ">= 16.0.0" } }, "node_modules/devlop": { @@ -16492,9 +16136,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.389", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", - "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, @@ -16567,9 +16211,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", - "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -16901,9 +16545,9 @@ } }, "node_modules/eslint": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", - "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -16913,7 +16557,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -16937,7 +16581,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -16976,18 +16620,18 @@ } }, "node_modules/eslint-plugin-react-dom": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.10.4.tgz", - "integrity": "sha512-+r4knNhpjjvPEitRWF6j3aiQjxHYcr93QnPFChgt7pChIuifEgIgYeeCj9qwgWk3rGt9YrsESe7s1d9q8iae3w==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.18.1.tgz", + "integrity": "sha512-0PakQ8iPZ2yq4zi99T28l+NlefxrE/92CpIQ1SbbFT2fqNIjRz1L+ewWIzAsMdyUUjQzcCj8domEJ36y20K8Zw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/jsx": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/jsx": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "compare-versions": "^6.1.1" }, "engines": { @@ -16999,19 +16643,19 @@ } }, "node_modules/eslint-plugin-react-jsx": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.10.4.tgz", - "integrity": "sha512-XVQs2v9pekKzvO2FUtqVLPdmq+UNLal1YPS3rWwcCYney41GzT2SA7lzQ/t9sRkoclQf/DTE7jqQiUHN7RBoDA==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.18.1.tgz", + "integrity": "sha512-bT8/pcYwp1Anyjnvjqz1jRJBGdo4ydXlR6hciyQUiHzOxAWpIOP61ggAVgbA9JU3qOr4/hldMpGZHdJP5pqfgw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/core": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/jsx": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1" + "@eslint-react/ast": "5.18.1", + "@eslint-react/core": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/jsx": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0" }, "engines": { "node": ">=22.0.0" @@ -17022,18 +16666,19 @@ } }, "node_modules/eslint-plugin-react-naming-convention": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.10.4.tgz", - "integrity": "sha512-prEXjlQM7s6xql23g4AETipAyhY3Fu9h4SbGS3LtRBhwBu+oB0MR1ksiZ2LK7jQr3jMb7ZgL7L5oYzbU/i5ZzQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.18.1.tgz", + "integrity": "sha512-fN6U+Q5QOZ7ZJlTn7XsKB0NvRx0qDp+7tV2Zht3nhtKQ2tl4cxFz8QV78l++FqMtBtCNfxTiInuo5u5Xt8Ymjw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/core": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/core": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "ts-pattern": "^5.9.0" }, "engines": { @@ -17045,19 +16690,19 @@ } }, "node_modules/eslint-plugin-react-rsc": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.10.4.tgz", - "integrity": "sha512-I6sJJW9ZrSrHPhOcV7oB4L8VLon60Q0DNMKOnsKthp0pk0Y0OTQ7CkPnHVzvGlOW80KdBZJFYLy8pRA4IHYV9w==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.18.1.tgz", + "integrity": "sha512-s792p16e8YNhGA3vgIseDxdrwTuNb+gHn0Gau5PsTZ42d8Zuvi3ZJWLfkAZfvMfblaDev6tSAv2fZzWB0brEIg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/core": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1" + "@eslint-react/ast": "5.18.1", + "@eslint-react/core": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0" }, "engines": { "node": ">=22.0.0" @@ -17068,20 +16713,20 @@ } }, "node_modules/eslint-plugin-react-web-api": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.10.4.tgz", - "integrity": "sha512-dI+7rmSuxKYtq9XjJVkowSgCiyIAc8IU6Xzt6uJagAl6vrnMxiDwnzKLwAKUheRQdNTMDOVqPY04gJ4m4YxsbA==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.18.1.tgz", + "integrity": "sha512-Az8kJEM2Bk8PhJR3UBLWBVZkir/2l+qX545PTi8Kx3JdDlK6IlU1vz69/B5tJ/YXo6cJKP3bKTqaAzcT5ikl8g==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/core": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", - "birecord": "^0.1.1", + "@eslint-react/ast": "5.18.1", + "@eslint-react/core": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", + "birecord": "^0.1.2", "ts-pattern": "^5.9.0" }, "engines": { @@ -17093,23 +16738,23 @@ } }, "node_modules/eslint-plugin-react-x": { - "version": "5.10.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-5.10.4.tgz", - "integrity": "sha512-Lslxif5p0rKviGUdxPc7KIsNkEwkaZGoqFcTi4/ji6Ht1NylJkcV80p4dU6ZLgdaIAaQD2QG/m3i4QuoxLXfFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-react/ast": "5.10.4", - "@eslint-react/core": "5.10.4", - "@eslint-react/eslint": "5.10.4", - "@eslint-react/jsx": "5.10.4", - "@eslint-react/shared": "5.10.4", - "@eslint-react/var": "5.10.4", - "@typescript-eslint/scope-manager": "^8.62.1", - "@typescript-eslint/type-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "@typescript-eslint/typescript-estree": "^8.62.1", - "@typescript-eslint/utils": "^8.62.1", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-x/-/eslint-plugin-react-x-5.18.1.tgz", + "integrity": "sha512-i0a17vUMoMhiHjcXz4IYfO42Br6xQkbu+PqVftUHM49i1bbYD+22GFMgNmKkLCMNKXZSQhocKvIKUwhMIbFTQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.18.1", + "@eslint-react/core": "5.18.1", + "@eslint-react/eslint": "5.18.1", + "@eslint-react/jsx": "5.18.1", + "@eslint-react/shared": "5.18.1", + "@eslint-react/var": "5.18.1", + "@typescript-eslint/scope-manager": "^8.65.0", + "@typescript-eslint/type-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "@typescript-eslint/typescript-estree": "^8.65.0", + "@typescript-eslint/utils": "^8.65.0", "compare-versions": "^6.1.1", "string-ts": "^2.3.1", "ts-api-utils": "^2.5.0", @@ -17168,6 +16813,22 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -17199,20 +16860,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -17816,9 +17463,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -18670,9 +18317,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -18763,9 +18410,9 @@ } }, "node_modules/globby": { - "version": "16.2.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.1.tgz", - "integrity": "sha512-JmsqJalahxxgW8V2ecSQ2G7UjPlI9cpKdrkG9KoNiXhd/YslXOTEB0cViENWUznuovIuNT+FkMbraDGjr4FCUg==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz", + "integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==", "dev": true, "license": "MIT", "dependencies": { @@ -18784,9 +18431,9 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -18872,46 +18519,6 @@ "dev": true, "license": "ISC" }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/gray-matter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -19428,9 +19035,9 @@ } }, "node_modules/html-webpack-plugin": { - "version": "5.6.7", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", - "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", + "version": "5.6.8", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.8.tgz", + "integrity": "sha512-MZmKQcTnhEh1SPSyMiEytIeDZDUoBZVorNHivQGXMASHf/BSGGOrKa2xQ5bGx3TCe1n109ecCt+cpww7wwWhKA==", "dev": true, "license": "MIT", "dependencies": { @@ -19448,7 +19055,7 @@ "url": "https://opencollective.com/html-webpack-plugin" }, "peerDependencies": { - "@rspack/core": "0.x || 1.x", + "@rspack/core": "0.x || 1.x || 2.x", "webpack": "^5.20.0" }, "peerDependenciesMeta": { @@ -20006,9 +19613,9 @@ } }, "node_modules/ipaddr.js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", - "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", "license": "MIT", "engines": { "node": ">= 10" @@ -22502,20 +22109,6 @@ "node": ">=4" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -23322,20 +22915,20 @@ } }, "node_modules/memfs": { - "version": "4.63.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.63.0.tgz", - "integrity": "sha512-97I4O8q0y8Gq/mNp3bTvCZjY6ojMVN3z0z7LaGnIYOPH6dKeLp/eYBC7uAK2m6Pnw4i56YlsZYXn2pSdmdrIlg==", + "version": "4.67.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.67.0.tgz", + "integrity": "sha512-yuwPWDAs2kfwpQFuFNQI2OkiJ4ZqkGvSFq2jbgC9pFPfgh1N1lPxJaBj5rHp9R1wfJlSzUQkFnSw6WYGPwBbRg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/fs-core": "4.63.0", - "@jsonjoy.com/fs-fsa": "4.63.0", - "@jsonjoy.com/fs-node": "4.63.0", - "@jsonjoy.com/fs-node-builtins": "4.63.0", - "@jsonjoy.com/fs-node-to-fsa": "4.63.0", - "@jsonjoy.com/fs-node-utils": "4.63.0", - "@jsonjoy.com/fs-print": "4.63.0", - "@jsonjoy.com/fs-snapshot": "4.63.0", + "@jsonjoy.com/fs-core": "4.67.0", + "@jsonjoy.com/fs-fsa": "4.67.0", + "@jsonjoy.com/fs-node": "4.67.0", + "@jsonjoy.com/fs-node-builtins": "4.67.0", + "@jsonjoy.com/fs-node-to-fsa": "4.67.0", + "@jsonjoy.com/fs-node-utils": "4.67.0", + "@jsonjoy.com/fs-print": "4.67.0", + "@jsonjoy.com/fs-snapshot": "4.67.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.0.1", @@ -23346,9 +22939,6 @@ "funding": { "type": "github", "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" } }, "node_modules/meow": { @@ -25746,9 +25336,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -28065,9 +27655,9 @@ } }, "node_modules/p-map": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", - "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", "dev": true, "license": "MIT", "engines": { @@ -28505,9 +28095,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -28758,35 +28348,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/playwright/node_modules/fsevents": { @@ -30348,9 +29938,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -30457,9 +30047,9 @@ "license": "MIT" }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -31255,9 +30845,9 @@ "license": "MIT" }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.3.tgz", - "integrity": "sha512-DRdx5neNsG/QXbniLFWi2YmC/68oeOOmKz6zOjVk6ZS1ZLXgLIKqVEc6hWsmkjBbgii0SwaBTcJ5XKj5gzY/4A==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -32241,9 +31831,9 @@ } }, "node_modules/semantic-release": { - "version": "25.0.5", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", - "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.8.tgz", + "integrity": "sha512-w/iZ0bur36rKffXZYmIUmy068eoBY3Ij1DCCddx2JwWEM5Tg+eU9ld/E9qSInVvPASyyR2Ln/XGfQ9OZrMlhtw==", "dev": true, "license": "MIT", "dependencies": { @@ -32546,9 +32136,9 @@ "license": "MIT" }, "node_modules/serve-handler/node_modules/brace-expansion": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", - "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -33329,13 +32919,6 @@ "through2": "~2.0.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -33360,17 +32943,6 @@ "ssh2": "^1.4.0" } }, - "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { - "version": "0.5.52", - "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", - "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/ssh2-streams": "*" - } - }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -33757,9 +33329,9 @@ } }, "node_modules/stylelint": { - "version": "17.14.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz", - "integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==", + "version": "17.14.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz", + "integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==", "dev": true, "funding": [ { @@ -33775,7 +33347,7 @@ "dependencies": { "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.1.5", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", @@ -33787,9 +33359,9 @@ "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.3", + "file-entry-cache": "^11.1.5", "global-modules": "^2.0.0", - "globby": "^16.2.0", + "globby": "^16.2.1", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", @@ -33799,12 +33371,12 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.15", + "postcss": "^8.5.16", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.4", "postcss-value-parser": "^4.2.0", "string-width": "^8.2.1", - "supports-hyperlinks": "^4.4.0", + "supports-hyperlinks": "^4.5.0", "svg-tags": "^1.0.0", "table": "^6.9.0", "write-file-atomic": "^7.0.1" @@ -33866,9 +33438,9 @@ } }, "node_modules/stylelint/node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -33992,9 +33564,9 @@ } }, "node_modules/stylelint/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -34015,9 +33587,9 @@ } }, "node_modules/stylelint/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { @@ -34631,9 +34203,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz", - "integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -34728,6 +34300,16 @@ "streamx": "^2.15.0" } }, + "node_modules/testcontainers/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -34762,9 +34344,9 @@ } }, "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", "dev": true, "license": "MIT", "engines": { @@ -35057,9 +34639,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -35069,7 +34651,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -35270,16 +34852,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", - "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.1", - "@typescript-eslint/parser": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1", - "@typescript-eslint/utils": "8.62.1" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -35293,160 +34875,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/project-service": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", - "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.1", - "@typescript-eslint/types": "^8.62.1", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", - "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", - "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", - "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", - "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.62.1", - "@typescript-eslint/tsconfig-utils": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/visitor-keys": "8.62.1", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", - "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.1", - "@typescript-eslint/types": "8.62.1", - "@typescript-eslint/typescript-estree": "8.62.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", - "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.62.1", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -36187,9 +35615,9 @@ } }, "node_modules/webpack": { - "version": "5.108.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", - "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "dev": true, "license": "MIT", "dependencies": { @@ -36199,22 +35627,20 @@ "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.2", + "enhanced-resolve": "^5.24.4", "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.2", "mime-db": "^1.54.0", "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", "watchpack": "^2.5.2", - "webpack-sources": "^3.5.0" + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -36270,9 +35696,9 @@ } }, "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "dev": true, "license": "MIT", "engines": { @@ -36292,9 +35718,9 @@ } }, "node_modules/webpack-cli": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.1.0.tgz", - "integrity": "sha512-pSJ5p5PkXRD88sfCq5Wo+coc42QykwRu5Md0DyESj0rT6PPPA2wTNabpHPKgqH8EMkfTDo3IWx3iiNXMu8XDBQ==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-7.2.2.tgz", + "integrity": "sha512-lD0pALneslq8FfV+rwvm1BMW0AFAJrHHhNupAGN4asYjMvqrtRsenU4iKpiBo09gS4ntMxKGUxl9jhTEzVt0oA==", "dev": true, "license": "MIT", "dependencies": { @@ -36323,7 +35749,7 @@ "toml": "^3.0.0 || ^4.0.0", "webpack": "^5.101.0", "webpack-bundle-analyzer": "^4.0.0 || ^5.0.0", - "webpack-dev-server": "^5.0.0" + "webpack-dev-server": "^5.0.0 || ^6.0.0" }, "peerDependenciesMeta": { "js-yaml": { @@ -36833,9 +36259,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 62ff3073b..2a8a21fd7 100644 --- a/package.json +++ b/package.json @@ -45,26 +45,26 @@ "node": ">=24.0.0" }, "devDependencies": { - "@eslint-react/eslint-plugin": "5.10.4", + "@eslint-react/eslint-plugin": "5.18.1", "@eslint/js": "10.0.1", - "@testing-library/jest-dom": "6.9.1", + "@testing-library/jest-dom": "7.0.0", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", "@types/jest": "30.0.0", "concurrently": "10.0.4", - "conventional-changelog-conventionalcommits": "10.2.0", - "eslint": "10.6.0", + "conventional-changelog-conventionalcommits": "10.2.1", + "eslint": "10.8.0", "eslint-config-prettier": "10.1.8", "identity-obj-proxy": "3.0.0", "jest": "30.4.2", "jest-environment-jsdom": "30.4.1", - "prettier": "3.9.4", - "semantic-release": "25.0.5", - "stylelint": "17.14.0", + "prettier": "3.9.6", + "semantic-release": "25.0.8", + "stylelint": "17.14.1", "stylelint-config-standard": "40.0.0", - "ts-jest": "29.4.11", + "ts-jest": "29.4.12", "typescript": "6.0.3", - "typescript-eslint": "8.62.1" + "typescript-eslint": "8.65.0" }, "overrides": { "minimatch@>=10.0.0 <10.2.3": "10.2.4", From 56980ed1d853ec817a01f3bc1350ffaff6f57c3b Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 17:34:11 +0200 Subject: [PATCH 15/42] fix(deps): remediate GHSA-rhx6-c78j-4q9w in brace-expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remediates GHSA-rhx6-c78j-4q9w (brace-expansion ReDoS, Critical) - Bumps brace-expansion 5→5.0.9, 2→2.1.4, 1→1.1.18 across all ranges - Lockfile-only change via `npm update brace-expansion` --- package-lock.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 00ad3cd73..cdd195e10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "react-dom": "19.2.8", "react-i18next": "17.0.11", "react-konva": "19.2.5", - "react-router-dom": "^7.18.2" + "react-router-dom": "7.18.2" }, "devDependencies": { "@babel/core": "7.29.7", @@ -12516,6 +12516,7 @@ "version": "13.0.2", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" From 4f23feb68a8e1cf7e78399e76f255627e2082663 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 17:53:16 +0200 Subject: [PATCH 16/42] docs(memory): record #1973 column-visibility rulings and the rejected floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Records #1973 column-visibility rulings and the rejected mandatory-floor - Records Rev 3 spec reconciliation: three-tier summary-label fallback, AC 3.7 chunk-budget clamp - Documents process failure: always say "body rewritten" after an issue-body revision 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude product-owner --- .claude/agent-memory/product-owner/MEMORY.md | 3 +- .../product-owner/bank-report-wizard.md | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.claude/agent-memory/product-owner/MEMORY.md b/.claude/agent-memory/product-owner/MEMORY.md index edf05067f..923e6f072 100644 --- a/.claude/agent-memory/product-owner/MEMORY.md +++ b/.claude/agent-memory/product-owner/MEMORY.md @@ -40,8 +40,9 @@ Full detail in [standalone-bugs-and-stories.md](standalone-bugs-and-stories.md) - Photo: #1723 lightbox picker UX (2026-06-16) - **DataTable: #1955** two-column toggle race silently hides 2nd column, all 6 DataTable pages (Should Have, S, Backlog, 2026-08-02). See [datatable-column-preference-race.md](datatable-column-preference-race.md) — records that **fast clicking is the SAFE case** (I judged this backwards; debounce `clearTimeout` coalesces rapid input, the >500ms reading-pace gap is the reachable one) and that #1920's E2E-only fix (`InvoicesPage.enableColumn()` awaits the PATCH) makes CI green **without** fixing production — don't close #1955 on a green shard. - **#1957** latent cross-file E2E test-isolation hazard (shared-admin `user_preferences` writes under `fullyParallel`, `LocaleContext.syncWithServer` actively flips a victim test's locale) — Should Have, bug, Backlog, 2026-08-02/03, filed from `/fix-e2e` work on PR #1956. Scoped as an audit + per-spec sweep, not a single-file fix — found a second live instance (`diary-uat-fixes.spec.ts` vs `dashboard.spec.ts`, key `dashboard.hiddenCards`) while researching it. Distinct from #1955 (production race) and #1920 (E2E workaround for #1955). Detail in [e2e-shared-admin-preference-hazard.md](e2e-shared-admin-preference-hazard.md). -- **Bank Report Wizard mini-epic** (no parent epic) — all rulings, contract facts, per-PR review outcomes and filed follow-ups in [bank-report-wizard.md](bank-report-wizard.md). Shipped: #1876→#1877→#1878→#1879, Round 2 #1898–#1901, Round 3 #1929–#1933 (all merged; #1929 took 4 rounds, #1925 closed as duplicate). **Open**: #1888 indicator, #1891 (2 wiki MUST FIX), #1895→#1896/#1897 claim close-out, #1910 `lang` attr, #1917 consolidated follow-ups (incl. `KI` glossary entry + `computeIncludedTotal` extraction), #1937/#1938 PDF header bugs, #1939 geometry hygiene, #1940/#1941/#1950, #1946 in-flight AI generation (Must Have), #1947 `useReducer`, #1952/#1953, #1965–#1972 (PR #1959 sweep). #1931 merged but **not Done** — ACs 3.2/3.3 need live-LLM UAT. +- **Bank Report Wizard mini-epic** (no parent epic) — all rulings, contract facts, per-PR review outcomes and filed follow-ups in [bank-report-wizard.md](bank-report-wizard.md). Shipped: #1876→#1877→#1878→#1879, Round 2 #1898–#1901, Round 3 #1929–#1933 (all merged; #1929 took 4 rounds, #1925 closed as duplicate). **Open**: #1888 indicator, #1891 (2 wiki MUST FIX), #1895→#1896/#1897 claim close-out, #1910 `lang` attr, #1917 consolidated follow-ups (incl. `KI` glossary entry + `computeIncludedTotal` extraction), #1937/#1938 PDF header bugs, #1939 geometry hygiene, #1940/#1941/#1950, #1946 in-flight AI generation (Must Have), #1947 `useReducer`, #1952/#1953, #1965–#1972 (PR #1959 sweep), **#1973** column visibility (Should Have, Todo, blocked-by #1965). #1931 merged but **not Done** — ACs 3.2/3.3 need live-LLM UAT. - **Reusable rulings from this cluster** (detail in [bank-report-wizard.md](bank-report-wizard.md), patterns in [pr-review-patterns.md](pr-review-patterns.md)): **merge is a code gate, Done is an acceptance gate** (unverifiable AC *with* a substitute assertion = documented deviation; *without* one → UAT, reopen on failure); **a finding that defeats the PR's own AC belongs in that PR, not a follow-up**; **closed/released ACs get a dated supersession comment, never a rewrite**; **ACs that misdescribe reality fail correct implementations at UAT** (seen 3×: #1943 AC4, #1933 AC2.1/2.7, my own #1925/#1932 transcription); **comment keeps the rationale, issue owns the guard**; bounded-and-quantified earns a tracked owner, unbounded-and-estimated gets documentation only. +- **#1973 column visibility wired through to the PDF** (user-story, Should Have, Todo, 2026-08-03, **blocked-by #1965**) — user reversed #1959's preview-only hint. **My proposed "at least one of Vendor/Invoice #" floor was rejected as an invented compliance rule**; only Allocated Amount is mandatory (survived because its justification is *structural* — summary amounts + #1959 inline labels live in that cell — not purposive). 96 legal subsets (overview 2^6=64, claim 2^5=32), floor 1 column. Rulings: legend stays **unconditional** (AC 6.1 forbids `if invoiceAmount hidden`) because `(less deposit)` was insufficient regardless of adjacency; base set **IS** the ceiling for a **data** reason (`status: isOverview ? status : null`) — corrected the coordinator's "arbitrary means no ceiling" reading; per-session state, not `useColumnPreferences`; narrower-than-page table when neither Usage nor Vendor visible. **#1966 CLOSED as superseded** (board Wont-Do) — its AC1 would pass while the PDF still contained every column. Recommended **after** the #1958 promotion. **Rev 3 (spec reconciliation)**: adopted the dev-team-lead's **three-tier summary-label fallback** over my "same cell" ruling (92 subsets last-leading-column → Invoice Amount → separate block beneath table for 4; tier 3 *increases* preview parity, `ReportContentEditor.tsx:442-445`); added **AC 3.7 one-sided chunk-budget clamp** (650 scales *down* never *up* — the hazard is a future *added* column narrowing Usage, not this change); 72 subsets = `printableWidth()`, 24 narrower (84.00–315.00pt). **Process failure: I rewrote the body but reported only the rulings, so two agents spec'd from a stale rev 1 and re-filed an already-fixed contradiction — always say "body rewritten, numbering reassigned".** Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1973 column visibility" + §"rev 3". ## Requirements Coverage diff --git a/.claude/agent-memory/product-owner/bank-report-wizard.md b/.claude/agent-memory/product-owner/bank-report-wizard.md index 0d441b3aa..efd131110 100644 --- a/.claude/agent-memory/product-owner/bank-report-wizard.md +++ b/.claude/agent-memory/product-owner/bank-report-wizard.md @@ -458,3 +458,37 @@ All parentless, Bank Report Wizard cluster. #1959 was the user's own PR and held | **#1970** | Configurable auth rate limits — see [auth-rate-limits-1970.md](auth-rate-limits-1970.md) | | **#1971** | `search-users.spec.ts` leftovers | | **#1972** | Column-preference saves fail silently + dead `isLoaded` | + +## #1973 column visibility (2026-08-03) — user overruled my invented floor + +**Requirement**: *"If i de-select a column in the preview it shouldn't render in the pdf"*, then, when asked which columns are mandatory: *"generalize the use case i want to be able to specify an arbitrary amount of columns - only the allocated amount is a mandatory column"*. + +**My floor was rejected.** I proposed "at least one of Vendor / Invoice # must remain, on row-auditability grounds." The user rejected it as an **invented compliance rule**. Allocated Amount alone is a legal document. **Lesson, generalizable: when I flag "I don't want to invent a compliance rule" and then invent one anyway on plausible-sounding domain reasoning, that is still inventing one.** Allocated Amount's own mandatory status survived only because it rests on two *structural* facts in this codebase (summary-row amounts and the #1959 inline labels both live in that cell), not on a domain claim. **Structural justification survives user scrutiny; purposive domain reasoning does not.** + +**The floor was accidentally protecting something real** — worth remembering as a pattern. `buildSummaryRow` puts the label in the last *leading* cell (before the amount columns). With no leading columns visible the label had nowhere to go and totals would print as bare numbers. Removing the floor promoted that from "impossible by construction" to "must be ruled on": R2 = no total ever prints unlabelled; where no leading cell exists the label goes **in the same cell as the amount**. **When a constraint is removed, re-derive what it was silently guaranteeing — the constraint's *reason* may have been wrong while its *effect* was load-bearing.** + +**Corrected the coordinator's reading of "arbitrary"** (Q5). It read as "the report type's base set is not a ceiling — a claim could re-add Status." Wrong: `buildReportContent.ts:203` sets `status: isOverview ? status : null`, so there is **no status value** for claim/proof-of-funds. Re-adding it renders a column of blanks in a bank document. Ruled: **the base set IS the ceiling, for a data reason not a policy one.** "Arbitrary" frees the subset among columns the report *has*; it does not conjure unproduced data. Named the alternative explicitly (make `buildReportContent` produce status for claims) so the user can request it rather than having me decide. **Generalizable: a user's scope-widening ruling does not implicitly authorize inventing data.** + +**Q3 — legend conditional or unconditional?** Ruled **unconditional**, #1965 **blocks** #1973 (`addBlockedBy` set). Hiding Invoice Amount destroys the adjacency that was my *entire* stated reason for accepting bare `(partial)` on PR #1959. Decisive argument against conditionality: **`(less deposit)` was already insufficient regardless of adjacency** (missing word = *separately*), so the legend block must print unconditionally anyway — conditioning the other sentence saves nothing and adds a branch whose output a reader cannot predict. AC 6.1 explicitly forbids implementing it as `if invoiceAmount hidden then legend`. Cross-link comment posted on #1965 so the raised necessity isn't lost. + +**Q4 persistence** — per-session in `ReportWizardPage` state, **not** `useColumnPreferences`. Strengthened by the ruling: 96 legal subsets with no floor means the right set varies per recipient, so a sticky per-user value is wrong more often than right *and* wrong invisibly. Resets on use-case change (third instance of the #1943/#1946 hazard — handled up front, not filed later). + +**Q7 (new, from the ruling)** — Usage is now hideable and it is the **only elastic column** (`USAGE_WIDTH_*COL` = leftover). Ruled the *observable outcome*, left the mechanism to the architect: width never exceeds `printableWidth()` (unconditional); surplus goes to a free-form text column (Usage, else Vendor); when neither is visible **the table renders narrower than the page, left-aligned** — a 2-column numeric table stretched across 515pt looks broken in a bank document. Degenerate case = one 75pt column. + +**Geometry facts pinned** (`overviewPdf.ts` / `pageGeometry.ts`): fixed widths Vendor 45, Invoice # 63, Date 46, Status 40, InvoiceAmount 48, Allocated 75; Usage = `usableColumnWidth(n) - fixedSum`. Subsets: overview 2^6=**64**, claim 2^5=**32**, **96 total**, counts 1–7. **Hiding a column can only make Usage *wider*** → per-line char counts rise, row heights fall, so every measurement-pinned bound moves in the *safe* direction (exception: hiding Usage itself). Noted in AC 3.6 as a mitigating fact for the implementer. + +**#1966 closed as superseded, not amended** (board Wont-Do, supersession comment with an AC→AC carry-forward table). Its Notes asserted "nothing about them should reach the generated PDF" — the deleted premise — and its AC1 asserted DOM removal only, which **would pass while the PDF still contained every column**. Amending would have left a tech-debt/test-only issue carrying a functional change and erased the record of the reversal. **Rule: when a user reverses a design decision, close the issue built on the old premise and carry its still-valid ACs forward with attribution — don't rewrite it.** + +**Sequencing: after the #1958 promotion.** Stated as my own opinion, not deferred. #1958 is green/CLEAN at 54 commits with #1959 in it; this blocks on #1965 anyway; and it generalizes the exact module that produced two real defects that day. The ruling made the surface *larger* (no floor → degenerate single-column geometry + summary-label relocation). The shipped hint is **honest** — a stale string is cheap to reverse, a malformed bank document is not. + +### #1973 rev 3 — spec reconciliation, and a stale-body process failure + +**Process failure worth avoiding: I rewrote the #1973 body (rev 1 → rev 2) but only reported the *rulings* in my handback, not "the body has been rewritten and the numbering changed."** The coordinator and `dev-team-lead` both then worked from a cached rev 1, and the dev-team-lead filed the 1-column floor as a *contradiction to be fixed* when it had already been fixed. Substance never diverged; only R/AC numbers did (rev 2 reassigned R1–R8 and the AC numbers wholesale). **Rule: when amending an already-reported issue body, say "body rewritten, numbering reassigned" explicitly in the handback, and put an amendment log in the issue's Notes.** #1973 now carries one. + +**Adopted the spec's answer over my own on the summary label.** I ruled "label in the same cell as the amount"; the spec's **three-tier fallback** is better and is now R2 + AC 4.6: last visible leading column (**92**/96 subsets) → Invoice Amount if visible → **separate two-column block beneath the table** (**4** subsets: `{allocatedAmount}` and `{allocatedAmount, usage}` × 2 use cases). Verified the parity argument on disk: `ReportContentEditor.tsx:442-445` renders `content.summaryRows` as its own block independent of column visibility, so the PDF *matches* the HTML preview exactly where in-table placement is impossible. **Tier 3 increases preview parity rather than costing it — a fallback that converges on the existing preview is strictly better than one that invents a new form.** + +**AC 3.7, the one-sided chunk-budget clamp — a real correction to my AC.** My 3.6 said "recompute `MAX_SAFE_USAGE_CHUNK_CHARS` from the subset's actual Usage width", which implies upward scaling is legitimate. It is not. Hiding columns only *widens* Usage (chars/line 16 → up to 50), so the 650 budget gets more conservative and this change cannot breach it. **The hazard is the opposite and arrives later: a future *added* column narrows Usage, drops the true ceiling below 650, and silently reinstates the #1929 content-loss defect.** 650 rests on a **single real-render measurement at one width**; extrapolating upward is what caused the round-3 defect. So the clamp **scales down, never up**, AC 3.7 requires a test for *both* directions, and a comment must record the asymmetry as deliberate so it isn't "optimised" away as dead code. **Generalizable: a bound pinned by one measurement may be scaled toward safety but never away from it — and one-sided clamps need a recorded reason or they read as bugs.** + +**Verified geometry figures** (taken as computed from the spec, not re-derived): **72** of 96 subsets equal `printableWidth()` (48 overview + 24 claim); **24** render narrower (16 + 8), totals **84.00pt** (`tableOffsetsTotal(1)` 9.00 + 75) to **315.00pt** (`tableOffsetsTotal(5)` 43.00 + 272). I independently spot-checked both endpoints and the 72/24 split against the constants and they hold. + +**Q5/R6 confirmed by the coordinator** ("your Q5 correction was right and I was wrong") — the type's base set is the ceiling for the `status: isOverview ? status : null` **data** reason. Note it is **R6** in rev 2+, not R7 as in rev 1. From 18a87be5b043a9b25961cff3f3c77e202c63246b Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 17:53:48 +0200 Subject: [PATCH 17/42] fix(deps): remediate GHSA-g4rg-993r-mgx8 in undici (credential leak/SSRF) - Remediates GHSA-g4rg-993r-mgx8 (undici credential leak/SSRF) - Override strategy: forces sub-7.29.0 transitive instances to 7.29.0 while allowing testcontainers to use 8.x - Replaces blanket `"undici": "7.28.0"` override that pinned below the patched threshold --- package-lock.json | 28 ++++++++++++++++++++++++---- package.json | 3 ++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index cdd195e10..07ae1c5e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -144,6 +144,16 @@ "undici": "^6.23.0" } }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@actions/io": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", @@ -9080,6 +9090,16 @@ "node": ">= 20" } }, + "node_modules/@semantic-release/github/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@semantic-release/npm": { "version": "13.1.5", "resolved": "https://registry.npmjs.org/@semantic-release/npm/-/npm-13.1.5.tgz", @@ -34891,13 +34911,13 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 2a8a21fd7..5adbd10f0 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "js-yaml": "3.15.0" }, "webpack-dev-server": "5.2.6", - "undici": "7.28.0", + "undici@>=6.0.0 <7.29.0": "7.29.0", + "http-proxy-middleware": "3.0.7", "uuid": "11.1.1" } From 3216e23339daf233d808fdd6762b8e92b7293c3c Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 17:53:57 +0200 Subject: [PATCH 18/42] fix(llm): rename to llmGateway, extract computeIncludedTotal to shared, fix wiki debt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `budgetExtraction/` → `llmGateway/` to reflect its broader LLM role - Switch gate from deprecated `autoItemizeEnabled` → `llmEnabled` in `llmGateway/index.ts` - Extract `computeIncludedTotal` to `shared/src/lib/reportMath.ts` (used by server + client); replace O(n²) `Array.includes` with `Set.has` - Fix `sourceId!` non-null assertion → proper early-return guard; add `GenerateReportContentResponse` type annotation - Correct `shared/src/types/sourceReport.ts` JSDoc for `budgetLines[]` scope - Wiki: fix ADR-034 claims, update API-Contract `LLM_NOT_CONFIGURED` gate flag, correct Security-Audit paths Fixes #1917 Fixes #1914 Co-Authored-By: Claude backend-developer Co-Authored-By: Claude dev-team-lead Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude product-architect Co-Authored-By: Claude qa-integration-tester Co-Authored-By: Claude security-engineer Co-Authored-By: Claude translator --- .../agent-memory/product-architect/MEMORY.md | 4 +- .../product-architect/client-pdf-pipeline.md | 78 +++++--- .../source-report-split-inference.md | 18 +- client/src/i18n/glossary.json | 6 +- .../lib/reportContent/buildReportContent.ts | 5 +- .../ReportWizardPage/ReportWizardPage.tsx | 4 +- server/src/plugins/config.ts | 2 +- server/src/routes/sourceReports.ts | 7 +- ...voiceAutoItemizeService.mergeLines.test.ts | 4 +- .../invoiceAutoItemizeService.test.ts | 6 +- .../src/services/invoiceAutoItemizeService.ts | 7 +- .../categoryMapping.test.ts | 0 .../categoryMapping.ts | 0 .../contentLimits.test.ts | 0 .../contentLimits.ts | 0 .../dueDateFallback.test.ts | 0 .../dueDateFallback.ts | 0 .../fixtures/dachdecker.txt | 0 .../fixtures/elektriker-rechnung.txt | 0 .../fixtures/fliesenleger.txt | 0 .../fixtures/installateur-pauschale.txt | 0 .../fixtures/obi-baumarkt.txt | 0 .../index.test.ts | 33 +++- .../{budgetExtraction => llmGateway}/index.ts | 6 +- .../openAICompatibleProvider.test.ts | 2 +- .../openAICompatibleProvider.ts | 0 .../prompts.test.ts | 2 +- .../prompts.ts | 0 .../providerProfiles.test.ts | 0 .../providerProfiles.ts | 0 .../{budgetExtraction => llmGateway}/types.ts | 2 + .../reportContentGenerationService.test.ts | 6 +- .../reportContentGenerationService.ts | 25 ++- shared/src/index.ts | 3 + shared/src/lib/reportMath.test.ts | 181 ++++++++++++++++++ shared/src/lib/reportMath.ts | 37 ++++ shared/src/types/config.ts | 5 +- shared/src/types/sourceReport.ts | 2 +- wiki | 2 +- 39 files changed, 363 insertions(+), 84 deletions(-) rename server/src/services/{budgetExtraction => llmGateway}/categoryMapping.test.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/categoryMapping.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/contentLimits.test.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/contentLimits.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/dueDateFallback.test.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/dueDateFallback.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/fixtures/dachdecker.txt (100%) rename server/src/services/{budgetExtraction => llmGateway}/fixtures/elektriker-rechnung.txt (100%) rename server/src/services/{budgetExtraction => llmGateway}/fixtures/fliesenleger.txt (100%) rename server/src/services/{budgetExtraction => llmGateway}/fixtures/installateur-pauschale.txt (100%) rename server/src/services/{budgetExtraction => llmGateway}/fixtures/obi-baumarkt.txt (100%) rename server/src/services/{budgetExtraction => llmGateway}/index.test.ts (83%) rename server/src/services/{budgetExtraction => llmGateway}/index.ts (89%) rename server/src/services/{budgetExtraction => llmGateway}/openAICompatibleProvider.test.ts (99%) rename server/src/services/{budgetExtraction => llmGateway}/openAICompatibleProvider.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/prompts.test.ts (99%) rename server/src/services/{budgetExtraction => llmGateway}/prompts.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/providerProfiles.test.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/providerProfiles.ts (100%) rename server/src/services/{budgetExtraction => llmGateway}/types.ts (95%) create mode 100644 shared/src/lib/reportMath.test.ts create mode 100644 shared/src/lib/reportMath.ts diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 8078fba36..494ca8c28 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -4,9 +4,9 @@ - [Recurring patterns & traps](recurring-patterns.md) — polymorphic FK cleanup, XOR CHECK vs SET NULL, forked-function drift, test smells, cross-layer contract drift, ajv `anyOf`, N+1 sites, async writes surviving state resets, cross-reference rot in documented-bound comments (#1939), usePreferences per-instance store + serialized-write-queue review (#1955), capability-retained-but-producer-removed (#1959), reinstated-producer-vs-negative-guards (#1965), AC reversal by a polish issue (#1959), amount-threshold booleans narrowing status-existence booleans (#1897), prettier is not CI-gated, single-occurrence delimiter guard tests + German ordinals vs list markers + pre-validating regex fix specs (#1952), `Pick<>` is not a forcing function + caller-supplied monotonic seq reintroduces the ref + cascade tables smuggle behaviour changes + neutralised-trigger-left-in-code (#1947), tier factory only forces the cases that spread it (#1988), regex mirroring a third-party grammar + `parseInt` trailing garbage + env vars documented in four places (#1970, PR #1989) - [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 +- [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; wiki + shared type JSDoc both fixed (API-Contract #1914, sourceReport.ts #1917/PR #1994) - [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 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** +- [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 debt fully PAID 2026-08-04 (#1914)**: width rule #1 (`max(horizontalRatio) <= 1`, not `_minWidth`), module table, override keys, dontBreakRows/height-bound section, injection-only locale contract. Still-open code defect: `merge.ts:134` footer uses interface `t` — needs an issue - [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) diff --git a/.claude/agent-memory/product-architect/client-pdf-pipeline.md b/.claude/agent-memory/product-architect/client-pdf-pipeline.md index e01588515..f8dad331a 100644 --- a/.claude/agent-memory/product-architect/client-pdf-pipeline.md +++ b/.claude/agent-memory/product-architect/client-pdf-pipeline.md @@ -20,19 +20,18 @@ ADR-034 in future reviews instead of re-deriving. **ADR-035 is the next free num ## Module seams (good decomposition, keep it) -| Module | Role | -| ------------------- | --------------------------------------------------------------- | -| `loader.ts` | Lazy `import()` of both packages, promise-cached | -| `shared.ts` | Page header/footer builders, table layout, PDF formatters | -| `coverLetterPdf.ts` | Pure fn: report -> cover-letter `Content[]` | -| `overviewPdf.ts` | Pure fn: report -> overview-table `Content[]` | -| `merge.ts` | Orchestration: fetch docs, build, pdfmake render, pdf-lib merge | -| `sinks.ts` | Output: download / preview blob URL / upload to Paperless | -| `types.ts` | `ReportPdfOptions`, `GeneratedReport`, `SkippedDocument` | - -Builders are pure and take `t: TFunction` -- they cannot use hooks, which is exactly why locale -and currency get dropped (see [[story-reviews]] B3). Anything locale-dependent must be threaded -in as a parameter. +**The authoritative module table is ADR-034's** (corrected 2026-08-04, issue #1914) — read it there +rather than duplicating it here, since this copy drifted twice. Current shape: `loader.ts`, +`pageGeometry.ts` (sole owner of the pt coordinate system, extracted #1939), `shared.ts` (header/footer +builders + `TABLE_LAYOUT` callbacks, **no formatters, no geometry constants**), `coverLetterPdf.ts`, +`overviewPdf.ts`, `merge.ts`, `sinks.ts`, `types.ts`, `index.ts`. + +Builders are pure -- they cannot use hooks, which is exactly why locale and currency got dropped +(see [[story-reviews]] B3). Anything locale-dependent must be threaded in as a parameter. Since #1900 +the builders take `(reportContent: ReportContent, …, t: TFunction)` and **no formatters at all**: +`buildReportContent` is the single place `reportFormatters` is applied. The residual `t` covers only +generation-time strings (`*N` skip-note reasons, cover-letter `Reference:`/`Subject:` prefixes) and is +always `reportT`. ## Lazy-loading contract (fragile -- verify on every change) @@ -107,11 +106,12 @@ Node API cannot expose computed widths is **wrong** — it can, it is just a pri the version. Same trick measures text: `pdfkit` + the Roboto TTF out of `vfs_fonts` gives `doc.widthOfString(s)` for exact fit checks (avg lowercase prose char ~4.68pt @10pt Roboto). -**Page geometry is scattered across three files** (`PAGE_TOP_MARGIN` in `shared.ts`, L/R/B inline +**Page geometry WAS scattered across four sites** (`PAGE_TOP_MARGIN` in `shared.ts`, L/R/B inline in `merge.ts`, printable-width prose comment in `overviewPdf.ts`, paddings in `TABLE_LAYOUT`). -Recommended in the PR #1935 review: one `pageGeometry` module exporting `PAGE_WIDTH/HEIGHT`, -`PAGE_MARGIN_*`, `CELL_PADDING_X`, `V_LINE_WIDTH`, `printableWidth()`, `printableHeight()`, -`tableOffsetsTotal(cols)`, `usableColumnWidth(cols)`. `tokens.css` is explicitly NOT the answer — +Recommended in the PR #1935 review and **since implemented as `pageGeometry.ts` (#1939)**, exporting +`PAGE_WIDTH/HEIGHT`, `PAGE_MARGIN_*`, `CELL_PADDING_X`, `V_LINE_WIDTH`, font sizes, `PDF_STYLES`, +`printableWidth()`, `printableHeight()`, `tableOffsetsTotal(cols)`, `usableColumnWidth(cols)`, +`headerFootprint()`, and the derived `PAGE_TOP_MARGIN` (now 93, not 75). `tokens.css` is explicitly NOT the answer — the pdfmake layer is its own pt coordinate system outside the design system. **`PAGE_TOP_MARGIN` (75) is derived from a single-line-header assumption.** `buildPageHeader` @@ -274,16 +274,40 @@ Two structurally different note kinds now share the legend block but **not** a n Regression to guard when adding a flag type: emitting one entry per flagged row. Assert `footnotes.length === N` (never `>= 1`) on a fixture where several rows share a flag. -### ADR-034 debt (owed, NOT yet written — carry this forward) - -1. Add the `dontBreakRows` lesson + the "bound the rendered cell, not a field" rule + both detection recipes. -2. **Minimum-bar rule #1 is wrong**: `table._minWidth <= 515.28` fails on correct code (`_minWidth` is the - widest unbreakable _word_, not the laid-out width). Correct check: `max(horizontalRatio) <= 1`. - B2's narrative is fine; the generalized rule was mis-transcribed. -3. Module table drifted twice: add `pageGeometry.ts` (#1939) and `index.ts`; drop "PDF-local formatters" from - `shared.ts` (deleted in review round 2) and move "table layout constants" to `pageGeometry.ts`. -4. Override-key list (line 148): drop `attachmentsNote` — unreachable since #1959. -5. Record the fixed 6-or-7 column-count constraint above. +### ADR-034 debt — PAID 2026-08-04 (issue #1914) + +All five items below are now in the wiki. Do not re-file them. + +1. `dontBreakRows` lesson + "bound the rendered cell, not a source field" + both detection recipes + (monotonic page count, channel-independence) → new section "Unbreakable rows are silently dropped", + plus minimum-bar rule #2 pointing at it. +2. **Minimum-bar rule #1 corrected.** The old `table._minWidth <= 515.28` was a mis-transcription of B2's + _diagnostic_ into a _correctness check_. Replaced by two render-derived assertions: + `max(node.positions[].horizontalRatio) <= 1` and + `tableOffsetsTotal(cols) + sum(widths[i]._calcWidth) <= printableWidth()`. **Verified in this pass:** + `horizontalRatio = (x - pageMargins.left) / innerWidth`, set at `pdfmake/js/DocumentContext.js:490`. + `horizontalRatio` appears **nowhere in the Cornerstone codebase** — the assertion is documented but not + yet implemented; `realRender.test.ts` uses the `_calcWidth` sum form only. +3. Module table rewritten: added `pageGeometry.ts` (sole owner of the pt coordinate system) and `index.ts`; + `shared.ts` no longer claims formatters or geometry constants. Recorded _why_ the formatters were + deleted (a PDF-local formatter is a second formatter bound to a different locale = B3). +4. Override keys corrected against `reportContent/overrideKeys.ts`: `coverLetter.{sender,recipient, +reference,subject,body,signature}` + `row..usageText`. `attachmentsNote` is gone; `signature` was + added with a **three-way precedence** the old flat sentence misdescribed — explicit override wins, + else re-derive from sender **only if `senderChanged`**, else baseline. +5. Fixed 6-or-7 column-count constraint recorded (blocks plumbing the column toggles into the PDF). + +Also folded in during the same pass: the "injection is the only channel" contract (4 numbered invariants + +grep guard, story #1899 / architect L5), the strengthened labels rule, and the `_minWidth` +diagnostic-vs-check pointer on B2's narrative. Six Deviation Log rows added. + +### `merge.ts` footer locale leak — STILL OPEN (code defect, not wiki debt) + +`merge.ts:134` is still `buildPageFooter(t('sourceReports.table.pageLabel'))` — the **interface** `t`. With +interface DE + report EN, page 2+ is footed `Seite 2 / 5` under an English report. Verified still present +2026-08-04. The header was fixed in #1938; the footer was not. Fix shape: add `pageLabel` to +`ReportContentLabels` (which is `reportT`-derived). Now recorded in ADR-034 as a known open violation — +**needs a GitHub issue**, it has only ever been a PR-review follow-up note. ## ADR-034 legend model, corrected in PR #1979 (wiki `03ed804`) diff --git a/.claude/agent-memory/product-architect/source-report-split-inference.md b/.claude/agent-memory/product-architect/source-report-split-inference.md index d5249cc6f..06f94f041 100644 --- a/.claude/agent-memory/product-architect/source-report-split-inference.md +++ b/.claude/agent-memory/product-architect/source-report-split-inference.md @@ -43,11 +43,19 @@ splitKind: 'lines' | 'deposits' | 'both'; Client becomes `† iff splitKind !== 'deposits'`, `‡ iff splitKind !== 'lines'` — no `.length` proxies. -## Wiki deviation to fix (open) - -`wiki/API-Contract.md` L~3610 and `shared/src/types/sourceReport.ts` both describe `budgetLines[]` -as _"all ibl lines per invoice (even portion 0)"_. Wrong — it is all of **this source's** ibl lines. -Pre-existing since #1878/#1891. Needs the correction + a Deviation Log row on API-Contract.md. +## `budgetLines[]` scope deviation — wiki FIXED, shared type FIXED + +Both `wiki/API-Contract.md` and `shared/src/types/sourceReport.ts` described `budgetLines[]` as +_"all ibl lines per invoice (even portion 0)"_. Wrong twice over: it is all of **this source's** ibl +lines, and `claim` reports additionally **skip** zero-contribution lines (`sourceReportService.ts` +step h: `if (type === 'claim' && portion === 0) continue`). Pre-existing since #1878/#1891. + +- **API-Contract.md: FIXED 2026-08-04** (issue #1914). Field description rewritten + a "Budget Line + Scope" note added (subtraction basis not inventory; `isSplit` is the _only_ answer to multi-source + funding; the `claim`-only zero filter) + a Deviation Log row. +- **`shared/src/types/sourceReport.ts:60`: FIXED 2026-08-04** (issue #1917, PR #1994). JSDoc corrected + to "Budget lines allocated to this invoice for the requested source only. Other sources' lines are + absent (not present with zero portion). Used as a subtraction basis for line-exclusion math." ## pdfmake width gotcha (confirmed by QA on #1898) diff --git a/client/src/i18n/glossary.json b/client/src/i18n/glossary.json index 5523efec7..a460b352f 100644 --- a/client/src/i18n/glossary.json +++ b/client/src/i18n/glossary.json @@ -2,7 +2,7 @@ "_meta": { "description": "Single source of truth for domain terminology translations. The translator agent enforces these.", "locales": ["de"], - "lastUpdated": "2026-06-15" + "lastUpdated": "2026-08-04" }, "terms": { "Work Item": { "de": { "singular": "Arbeitspaket", "plural": "Arbeitspakete" } }, @@ -44,6 +44,8 @@ }, "Itemize": { "de": { "verb": "aufschlüsseln", "noun": "Aufschlüsselung" } }, "Orientation": { "de": { "singular": "Ausrichtung", "plural": "Ausrichtungen" } }, - "Correspondent": { "de": { "singular": "Korrespondent", "plural": "Korrespondenten" } } + "Correspondent": { "de": { "singular": "Korrespondent", "plural": "Korrespondenten" } }, + "AI": { "de": { "singular": "KI" } }, + "AI Assistance": { "de": { "singular": "KI-Unterstützung" } } } } diff --git a/client/src/lib/reportContent/buildReportContent.ts b/client/src/lib/reportContent/buildReportContent.ts index 77f79a60d..dfbcf1e9c 100644 --- a/client/src/lib/reportContent/buildReportContent.ts +++ b/client/src/lib/reportContent/buildReportContent.ts @@ -4,6 +4,7 @@ * No PDF-specific markup; no pdfmake Content objects. */ import type { TFunction } from 'i18next'; +import { computeIncludedTotal } from '@cornerstone/shared'; import type { SourceReportResponse, SourceReportType, @@ -218,9 +219,7 @@ export function buildReportContent( // Build summary rows (single total row only) const summaryRows: ReportContentSummaryRow[] = []; - const includedTotal = report.invoices - .filter((inv) => includedInvoiceIds.has(inv.invoiceId)) - .reduce((sum, inv) => sum + inv.allocatedAmount, 0); + const includedTotal = computeIncludedTotal(report, Array.from(includedInvoiceIds), new Set()); const totalAmountText = reportFormatters ? reportFormatters.formatCurrency(includedTotal) : '—'; summaryRows.push({ diff --git a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx index 3bb2afe7d..8f217244e 100644 --- a/client/src/pages/ReportWizardPage/ReportWizardPage.tsx +++ b/client/src/pages/ReportWizardPage/ReportWizardPage.tsx @@ -533,7 +533,7 @@ export function ReportWizardPage() { // Run AI generation const runAiGeneration = useCallback(async () => { - if (!report || !useCase) return; + if (!report || !useCase || !sourceId) return; const effectiveReport = applyLineExclusions(report, excludedLineIds); const includedInvoiceIds = Array.from( @@ -555,7 +555,7 @@ export function ReportWizardPage() { try { const result = await generateReportContent({ type: useCase, - sourceId: sourceId!, + sourceId, language: reportLanguage, includedInvoiceIds, excludedLineIds: Array.from(excludedLineIds), diff --git a/server/src/plugins/config.ts b/server/src/plugins/config.ts index cb62d222b..9bee99e42 100644 --- a/server/src/plugins/config.ts +++ b/server/src/plugins/config.ts @@ -46,7 +46,7 @@ export interface AppConfig { /** * Provider profile that shapes the outbound request body. Set explicitly * via `LLM_PROVIDER`, otherwise auto-detected from `LLM_BASE_URL`, with - * fallback to `'generic'`. See `services/budgetExtraction/providerProfiles.ts`. + * fallback to `'generic'`. See `services/llmGateway/providerProfiles.ts`. */ llmProvider: 'openai' | 'anthropic' | 'gemini' | 'ollama' | 'generic'; autoItemizeEnabled: boolean; diff --git a/server/src/routes/sourceReports.ts b/server/src/routes/sourceReports.ts index 259d8275d..decb67e26 100644 --- a/server/src/routes/sourceReports.ts +++ b/server/src/routes/sourceReports.ts @@ -6,6 +6,7 @@ import type { SourceReportType, MarkClaimedRequest, GenerateReportContentRequest, + GenerateReportContentResponse, } from '@cornerstone/shared'; export default async function sourceReportRoutes(fastify: FastifyInstance) { @@ -162,7 +163,11 @@ export default async function sourceReportRoutes(fastify: FastifyInstance) { throw new UnauthorizedError(); } - const result = await generateReportContent(fastify.db, fastify.config, request.body); + const result: GenerateReportContentResponse = await generateReportContent( + fastify.db, + fastify.config, + request.body, + ); return reply.status(200).send(result); }, diff --git a/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts b/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts index fe78ce360..79107940e 100644 --- a/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts +++ b/server/src/services/invoiceAutoItemizeService.mergeLines.test.ts @@ -252,9 +252,9 @@ describe('invoiceAutoItemizeService.mergeLines()', () => { // ─── Error propagation ──────────────────────────────────────────────────────── describe('error propagation', () => { - it('throws LlmNotConfiguredError when autoItemizeEnabled is false', async () => { + it('throws LlmNotConfiguredError when llmEnabled is false', async () => { await expect( - mergeLines(db, makeConfig({ autoItemizeEnabled: false }), DEFAULT_REQUEST), + mergeLines(db, makeConfig({ llmEnabled: false }), DEFAULT_REQUEST), ).rejects.toThrow(LlmNotConfiguredError); // No fetch call should have been made diff --git a/server/src/services/invoiceAutoItemizeService.test.ts b/server/src/services/invoiceAutoItemizeService.test.ts index bfc819de3..a5dfd3ee9 100644 --- a/server/src/services/invoiceAutoItemizeService.test.ts +++ b/server/src/services/invoiceAutoItemizeService.test.ts @@ -1612,7 +1612,7 @@ describe('invoiceAutoItemizeService', () => { ).rejects.toThrow(); }); - it('throws LlmNotConfiguredError when autoItemizeEnabled is false', async () => { + it('throws LlmNotConfiguredError when llmEnabled is false', async () => { const vendorId = insertVendor(db); const invoiceId = insertInvoice(db, vendorId, 500); linkDocument(db, invoiceId, 42); @@ -1621,7 +1621,7 @@ describe('invoiceAutoItemizeService', () => { .mockResolvedValueOnce(makeOkFetch(makePaperlessRawDoc())) .mockResolvedValueOnce(makeOkFetch(PAPERLESS_TAGS_RESPONSE)); - const config = makeConfig({ autoItemizeEnabled: false }); + const config = makeConfig({ llmEnabled: false }); await expect( autoItemize( @@ -1647,7 +1647,7 @@ describe('invoiceAutoItemizeService', () => { .mockResolvedValueOnce(makeOkFetch(makePaperlessRawDoc())) .mockResolvedValueOnce(makeOkFetch(PAPERLESS_TAGS_RESPONSE)); - const config = makeConfig({ autoItemizeEnabled: false }); + const config = makeConfig({ llmEnabled: false }); let caught: unknown; try { diff --git a/server/src/services/invoiceAutoItemizeService.ts b/server/src/services/invoiceAutoItemizeService.ts index f69a37022..371540c75 100644 --- a/server/src/services/invoiceAutoItemizeService.ts +++ b/server/src/services/invoiceAutoItemizeService.ts @@ -31,7 +31,7 @@ import { validateExtractedLines, computeDueDateFallback, mapCategoryNameToId, -} from './budgetExtraction/index.js'; +} from './llmGateway/index.js'; import * as paperlessService from './paperlessService.js'; import * as invoiceBudgetLineService from './invoiceBudgetLineService.js'; import * as invoiceService from './invoiceService.js'; @@ -326,9 +326,8 @@ export function persistLines( // Look up the budget line in the appropriate table let existingBudgetLine: - | typeof workItemBudgets.$inferSelect - | typeof householdItemBudgets.$inferSelect - | undefined = undefined; + typeof workItemBudgets.$inferSelect | typeof householdItemBudgets.$inferSelect | undefined = + undefined; if (extractedLine.assignedBudgetLineType === 'work_item') { existingBudgetLine = db diff --git a/server/src/services/budgetExtraction/categoryMapping.test.ts b/server/src/services/llmGateway/categoryMapping.test.ts similarity index 100% rename from server/src/services/budgetExtraction/categoryMapping.test.ts rename to server/src/services/llmGateway/categoryMapping.test.ts diff --git a/server/src/services/budgetExtraction/categoryMapping.ts b/server/src/services/llmGateway/categoryMapping.ts similarity index 100% rename from server/src/services/budgetExtraction/categoryMapping.ts rename to server/src/services/llmGateway/categoryMapping.ts diff --git a/server/src/services/budgetExtraction/contentLimits.test.ts b/server/src/services/llmGateway/contentLimits.test.ts similarity index 100% rename from server/src/services/budgetExtraction/contentLimits.test.ts rename to server/src/services/llmGateway/contentLimits.test.ts diff --git a/server/src/services/budgetExtraction/contentLimits.ts b/server/src/services/llmGateway/contentLimits.ts similarity index 100% rename from server/src/services/budgetExtraction/contentLimits.ts rename to server/src/services/llmGateway/contentLimits.ts diff --git a/server/src/services/budgetExtraction/dueDateFallback.test.ts b/server/src/services/llmGateway/dueDateFallback.test.ts similarity index 100% rename from server/src/services/budgetExtraction/dueDateFallback.test.ts rename to server/src/services/llmGateway/dueDateFallback.test.ts diff --git a/server/src/services/budgetExtraction/dueDateFallback.ts b/server/src/services/llmGateway/dueDateFallback.ts similarity index 100% rename from server/src/services/budgetExtraction/dueDateFallback.ts rename to server/src/services/llmGateway/dueDateFallback.ts diff --git a/server/src/services/budgetExtraction/fixtures/dachdecker.txt b/server/src/services/llmGateway/fixtures/dachdecker.txt similarity index 100% rename from server/src/services/budgetExtraction/fixtures/dachdecker.txt rename to server/src/services/llmGateway/fixtures/dachdecker.txt diff --git a/server/src/services/budgetExtraction/fixtures/elektriker-rechnung.txt b/server/src/services/llmGateway/fixtures/elektriker-rechnung.txt similarity index 100% rename from server/src/services/budgetExtraction/fixtures/elektriker-rechnung.txt rename to server/src/services/llmGateway/fixtures/elektriker-rechnung.txt diff --git a/server/src/services/budgetExtraction/fixtures/fliesenleger.txt b/server/src/services/llmGateway/fixtures/fliesenleger.txt similarity index 100% rename from server/src/services/budgetExtraction/fixtures/fliesenleger.txt rename to server/src/services/llmGateway/fixtures/fliesenleger.txt diff --git a/server/src/services/budgetExtraction/fixtures/installateur-pauschale.txt b/server/src/services/llmGateway/fixtures/installateur-pauschale.txt similarity index 100% rename from server/src/services/budgetExtraction/fixtures/installateur-pauschale.txt rename to server/src/services/llmGateway/fixtures/installateur-pauschale.txt diff --git a/server/src/services/budgetExtraction/fixtures/obi-baumarkt.txt b/server/src/services/llmGateway/fixtures/obi-baumarkt.txt similarity index 100% rename from server/src/services/budgetExtraction/fixtures/obi-baumarkt.txt rename to server/src/services/llmGateway/fixtures/obi-baumarkt.txt diff --git a/server/src/services/budgetExtraction/index.test.ts b/server/src/services/llmGateway/index.test.ts similarity index 83% rename from server/src/services/budgetExtraction/index.test.ts rename to server/src/services/llmGateway/index.test.ts index d8c55f9ed..2769a23a7 100644 --- a/server/src/services/budgetExtraction/index.test.ts +++ b/server/src/services/llmGateway/index.test.ts @@ -1,9 +1,10 @@ /** - * Unit and integration tests for budgetExtraction/index.ts + * Unit and integration tests for llmGateway/index.ts * * Tests cover: - * - getProvider() with autoItemizeEnabled: true returns a BudgetExtractionProvider - * - getProvider() with autoItemizeEnabled: false throws LlmNotConfiguredError + * - getProvider() with llmEnabled: true returns a BudgetExtractionProvider + * - getProvider() with llmEnabled: false throws LlmNotConfiguredError + * - The gate checks llmEnabled specifically (not autoItemizeEnabled) * - The returned provider's extract() method calls fetch end-to-end (smoke test) * - Re-exports are available (validateExtractedLines, createOpenAICompatibleProvider) */ @@ -85,14 +86,14 @@ const fetchSpy = mockFetch; // ─── getProvider() ──────────────────────────────────────────────────────────── describe('getProvider()', () => { - it('throws LlmNotConfiguredError when autoItemizeEnabled is false', () => { - const config = makeConfig({ autoItemizeEnabled: false }); + it('throws LlmNotConfiguredError when llmEnabled is false', () => { + const config = makeConfig({ llmEnabled: false }); expect(() => getProvider(config)).toThrow(LlmNotConfiguredError); }); it('LlmNotConfiguredError has code LLM_NOT_CONFIGURED', () => { - const config = makeConfig({ autoItemizeEnabled: false }); + const config = makeConfig({ llmEnabled: false }); try { getProvider(config); @@ -103,7 +104,7 @@ describe('getProvider()', () => { }); it('LlmNotConfiguredError has statusCode 503', () => { - const config = makeConfig({ autoItemizeEnabled: false }); + const config = makeConfig({ llmEnabled: false }); try { getProvider(config); @@ -113,7 +114,14 @@ describe('getProvider()', () => { } }); - it('returns a provider object when autoItemizeEnabled is true', () => { + it('throws when llmEnabled is false even if autoItemizeEnabled is true', () => { + // The gate is llmEnabled, not autoItemizeEnabled — verify the discriminating case + const config = makeConfig({ llmEnabled: false, autoItemizeEnabled: true }); + + expect(() => getProvider(config)).toThrow(LlmNotConfiguredError); + }); + + it('returns a provider object when llmEnabled is true', () => { const config = makeLlmConfig(); const provider = getProvider(config); @@ -121,6 +129,15 @@ describe('getProvider()', () => { expect(typeof provider.extract).toBe('function'); }); + it('returns a provider when llmEnabled is true even if autoItemizeEnabled is false', () => { + // autoItemizeEnabled is a separate flag; the gateway gate is llmEnabled only + const config = makeLlmConfig({ llmEnabled: true, autoItemizeEnabled: false }); + const provider = getProvider(config); + + expect(provider).toBeDefined(); + expect(typeof provider.extract).toBe('function'); + }); + it('returns a provider with an extract function that returns a Promise', () => { const config = makeLlmConfig(); const provider = getProvider(config); diff --git a/server/src/services/budgetExtraction/index.ts b/server/src/services/llmGateway/index.ts similarity index 89% rename from server/src/services/budgetExtraction/index.ts rename to server/src/services/llmGateway/index.ts index d5ce3778d..44cfb4b01 100644 --- a/server/src/services/budgetExtraction/index.ts +++ b/server/src/services/llmGateway/index.ts @@ -1,5 +1,5 @@ /** - * Budget extraction service — orchestrates OCR text to line item extraction via LLM. + * LLM gateway service — orchestrates OCR text to line item extraction via LLM. */ import { createOpenAICompatibleProvider } from './openAICompatibleProvider.js'; @@ -13,10 +13,10 @@ import { LlmNotConfiguredError } from '../../errors/AppError.js'; * * @param config - Application configuration * @returns BudgetExtractionProvider instance - * @throws LlmNotConfiguredError if autoItemizeEnabled is false + * @throws LlmNotConfiguredError if llmEnabled is false */ export function getProvider(config: AppConfig): BudgetExtractionProvider { - if (!config.autoItemizeEnabled) { + if (!config.llmEnabled) { throw new LlmNotConfiguredError('LLM gateway is not configured'); } return createOpenAICompatibleProvider({ diff --git a/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts b/server/src/services/llmGateway/openAICompatibleProvider.test.ts similarity index 99% rename from server/src/services/budgetExtraction/openAICompatibleProvider.test.ts rename to server/src/services/llmGateway/openAICompatibleProvider.test.ts index b4dbc860c..9d9865b3a 100644 --- a/server/src/services/budgetExtraction/openAICompatibleProvider.test.ts +++ b/server/src/services/llmGateway/openAICompatibleProvider.test.ts @@ -33,7 +33,7 @@ import { readFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; // Fixtures directory resolved from project root (process.cwd() = project root when jest runs) -const FIXTURES_DIR = resolve(process.cwd(), 'server/src/services/budgetExtraction/fixtures'); +const FIXTURES_DIR = resolve(process.cwd(), 'server/src/services/llmGateway/fixtures'); // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/server/src/services/budgetExtraction/openAICompatibleProvider.ts b/server/src/services/llmGateway/openAICompatibleProvider.ts similarity index 100% rename from server/src/services/budgetExtraction/openAICompatibleProvider.ts rename to server/src/services/llmGateway/openAICompatibleProvider.ts diff --git a/server/src/services/budgetExtraction/prompts.test.ts b/server/src/services/llmGateway/prompts.test.ts similarity index 99% rename from server/src/services/budgetExtraction/prompts.test.ts rename to server/src/services/llmGateway/prompts.test.ts index fcae5b48f..4be6fcb56 100644 --- a/server/src/services/budgetExtraction/prompts.test.ts +++ b/server/src/services/llmGateway/prompts.test.ts @@ -23,7 +23,7 @@ import { REPORT_CONTENT_LIMITS } from './contentLimits.js'; import type { GenerateReportContentLlmInput, GenerateReportContentLlmInvoice } from './types.js'; // Fixtures directory resolved from project root (process.cwd() = project root when jest runs) -const FIXTURES_DIR = resolve(process.cwd(), 'server/src/services/budgetExtraction/fixtures'); +const FIXTURES_DIR = resolve(process.cwd(), 'server/src/services/llmGateway/fixtures'); describe('SYSTEM_PROMPT', () => { it('mentions German construction invoice domain scoping', () => { diff --git a/server/src/services/budgetExtraction/prompts.ts b/server/src/services/llmGateway/prompts.ts similarity index 100% rename from server/src/services/budgetExtraction/prompts.ts rename to server/src/services/llmGateway/prompts.ts diff --git a/server/src/services/budgetExtraction/providerProfiles.test.ts b/server/src/services/llmGateway/providerProfiles.test.ts similarity index 100% rename from server/src/services/budgetExtraction/providerProfiles.test.ts rename to server/src/services/llmGateway/providerProfiles.test.ts diff --git a/server/src/services/budgetExtraction/providerProfiles.ts b/server/src/services/llmGateway/providerProfiles.ts similarity index 100% rename from server/src/services/budgetExtraction/providerProfiles.ts rename to server/src/services/llmGateway/providerProfiles.ts diff --git a/server/src/services/budgetExtraction/types.ts b/server/src/services/llmGateway/types.ts similarity index 95% rename from server/src/services/budgetExtraction/types.ts rename to server/src/services/llmGateway/types.ts index 23d97536e..5f34cd879 100644 --- a/server/src/services/budgetExtraction/types.ts +++ b/server/src/services/llmGateway/types.ts @@ -27,6 +27,7 @@ export interface GenerateReportContentLlmInvoice { vendorName: string; invoiceNumber: string | null; date: string; + /** Major currency units, 2 dp — not cents. */ amount: number; notes: string | null; budgetLines: GenerateReportContentLlmInvoiceLine[]; @@ -37,6 +38,7 @@ export interface GenerateReportContentLlmInput { reportType: string; // SourceReportType sourceName: string; sourceType: string; // BudgetSourceType + /** Major currency units, 2 dp — not cents. */ totalAmount: number; currency: string; invoices: GenerateReportContentLlmInvoice[]; diff --git a/server/src/services/reportContentGenerationService.test.ts b/server/src/services/reportContentGenerationService.test.ts index 62d77ee72..9d4a13e8b 100644 --- a/server/src/services/reportContentGenerationService.test.ts +++ b/server/src/services/reportContentGenerationService.test.ts @@ -5,7 +5,7 @@ * sourceReportService.test.ts) — the interesting behavior of this service is how it assembles * GenerateReportContentLlmInput from real DB rows (filtering, truncation, includedTotal math, * linked-item enrichment), so the DB layer is NOT mocked. The LLM provider itself IS mocked (via - * jest.unstable_mockModule on './budgetExtraction/index.js') so tests can assert directly on the + * jest.unstable_mockModule on './llmGateway/index.js') so tests can assert directly on the * exact `input` object handed to `provider.generateReportContent(input)` and control its return * value precisely — this is a cleaner seam than stubbing globalThis.fetch and parsing prompt text, * and it keeps this file scoped to reportContentGenerationService.ts's own orchestration logic @@ -39,7 +39,7 @@ import type { BudgetExtractionProvider, GenerateReportContentLlmInput, GenerateReportContentLlmResult, -} from './budgetExtraction/types.js'; +} from './llmGateway/types.js'; import type * as ReportContentGenerationServiceModule from './reportContentGenerationService.js'; // ─── Mock the LLM provider seam (getProvider) ───────────────────────────────── @@ -48,7 +48,7 @@ const mockProviderGenerateReportContent = jest.fn<(input: GenerateReportContentLlmInput) => Promise>(); const mockGetProvider = jest.fn<(config: AppConfig) => BudgetExtractionProvider>(); -jest.unstable_mockModule('./budgetExtraction/index.js', () => ({ +jest.unstable_mockModule('./llmGateway/index.js', () => ({ getProvider: mockGetProvider, })); diff --git a/server/src/services/reportContentGenerationService.ts b/server/src/services/reportContentGenerationService.ts index 65ac1760b..d6fb001b4 100644 --- a/server/src/services/reportContentGenerationService.ts +++ b/server/src/services/reportContentGenerationService.ts @@ -13,11 +13,12 @@ import type { GenerateReportContentLlmInvoice, GenerateReportContentLlmInvoiceLine, GenerateReportContentLlmResult, -} from './budgetExtraction/types.js'; -import { getProvider } from './budgetExtraction/index.js'; +} from './llmGateway/types.js'; +import { getProvider } from './llmGateway/index.js'; import { getSourceReport } from './sourceReportService.js'; import { EmptySelectionError } from '../errors/AppError.js'; import type { AppConfig } from '../plugins/config.js'; +import { computeIncludedTotal } from '@cornerstone/shared'; type DbType = BetterSQLite3Database; @@ -61,14 +62,15 @@ export async function generateReportContent( // Build excludedLineIds Set for fast lookup const excludedLineIds = new Set(body.excludedLineIds ?? []); + const includedInvoiceIdSet = new Set(includedInvoiceIds); - // Compute includedTotal using excluded lines logic - // (mirrors client's applyLineExclusions: sum allocatedAmount of non-excluded lines) - // Also track per-invoice exclusion-adjusted amounts for LLM input - let includedTotal = 0; + // Compute includedTotal using shared utility + const includedTotal = computeIncludedTotal(report, includedInvoiceIds, excludedLineIds); + + // Build per-invoice exclusion-adjusted amounts for LLM input const invoiceAmountsAdjusted = new Map(); for (const inv of report.invoices) { - if (!includedInvoiceIds.includes(inv.invoiceId)) { + if (!includedInvoiceIdSet.has(inv.invoiceId)) { continue; // Not in included set } // Start with invoice's allocated amount @@ -82,14 +84,11 @@ export async function generateReportContent( // Round to nearest cent (hundredth) invContribution = Math.round(invContribution * 100) / 100; invoiceAmountsAdjusted.set(inv.invoiceId, invContribution); - includedTotal += invContribution; } - // Round to nearest cent - includedTotal = Math.round(includedTotal * 100) / 100; // Fetch invoice notes and linked-item descriptions in bulk const invoiceIds = report.invoices - .filter((inv) => includedInvoiceIds.includes(inv.invoiceId)) + .filter((inv) => includedInvoiceIdSet.has(inv.invoiceId)) .map((inv) => inv.invoiceId); // Fetch invoices for notes @@ -107,7 +106,7 @@ export async function generateReportContent( const linkedItemIds = new Set(); const linkedItemTypes = new Map(); for (const inv of report.invoices) { - if (!includedInvoiceIds.includes(inv.invoiceId)) { + if (!includedInvoiceIdSet.has(inv.invoiceId)) { continue; } for (const line of inv.budgetLines) { @@ -149,7 +148,7 @@ export async function generateReportContent( // Build GenerateReportContentLlmInput const llmInvoices: GenerateReportContentLlmInvoice[] = []; for (const inv of report.invoices) { - if (!includedInvoiceIds.includes(inv.invoiceId)) { + if (!includedInvoiceIdSet.has(inv.invoiceId)) { continue; } diff --git a/shared/src/index.ts b/shared/src/index.ts index e4fe30aef..65224f51c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -420,6 +420,9 @@ export type { HouseholdSettingsResponse, } from './types/settings.js'; +// Source Report Math +export { computeIncludedTotal } from './lib/reportMath.js'; + // Source Reports export type { SourceReportType, diff --git a/shared/src/lib/reportMath.test.ts b/shared/src/lib/reportMath.test.ts new file mode 100644 index 000000000..95734ad64 --- /dev/null +++ b/shared/src/lib/reportMath.test.ts @@ -0,0 +1,181 @@ +/** + * Unit tests for shared/src/lib/reportMath.ts — computeIncludedTotal(). + * + * All fixtures are minimal: only the fields consumed by computeIncludedTotal + * (invoiceId, allocatedAmount, budgetLines[].{id, allocatedPortion}) are + * meaningful. The remaining required fields are filled with sentinel values. + */ + +import { describe, it, expect } from '@jest/globals'; +import { computeIncludedTotal } from './reportMath.js'; +import type { SourceReportResponse, SourceReportInvoice } from '../types/sourceReport.js'; + +// ─── Fixture helpers ────────────────────────────────────────────────────────── + +function makeInvoice( + invoiceId: string, + allocatedAmount: number, + budgetLines: Array<{ id: string; allocatedPortion: number }> = [], +): SourceReportInvoice { + return { + invoiceId, + vendorId: 'vendor-1', + vendorName: 'Test Vendor', + invoiceNumber: null, + date: '2026-01-01', + status: 'paid', + invoiceAmount: allocatedAmount, + allocatedAmount, + lineKind: 'invoice', + isSplit: false, + documents: [], + budgetLines: budgetLines.map(({ id, allocatedPortion }) => ({ + id, + description: null, + allocatedPortion, + linkedItem: null, + })), + deposits: [], + }; +} + +function makeReport(invoices: SourceReportInvoice[]): SourceReportResponse { + return { + type: 'claim', + source: { + id: 'src-1', + name: 'Test Source', + sourceType: 'bank_loan', + reference: null, + contactAddress: null, + }, + invoices, + totalAmount: invoices.reduce((s, i) => s + i.allocatedAmount, 0), + unallocatedInvoices: [], + generatedAt: '2026-01-01T00:00:00.000Z', + }; +} + +// ─── computeIncludedTotal() ─────────────────────────────────────────────────── + +describe('computeIncludedTotal()', () => { + // Scenario 1: empty includedInvoiceIds → returns 0 + it('returns 0 when includedInvoiceIds is empty', () => { + const report = makeReport([makeInvoice('inv-1', 500), makeInvoice('inv-2', 300)]); + const result = computeIncludedTotal(report, [], new Set()); + expect(result).toBe(0); + }); + + // Scenario 2: all invoices included, no exclusions → sums all allocatedAmount values + it('sums all allocatedAmount values when all invoices are included and no lines are excluded', () => { + const report = makeReport([ + makeInvoice('inv-1', 100), + makeInvoice('inv-2', 200), + makeInvoice('inv-3', 50), + ]); + const result = computeIncludedTotal(report, ['inv-1', 'inv-2', 'inv-3'], new Set()); + expect(result).toBe(350); + }); + + // Scenario 3: subset of invoices included → only matching invoices contribute + it('only sums invoices whose IDs appear in includedInvoiceIds', () => { + const report = makeReport([ + makeInvoice('inv-1', 100), + makeInvoice('inv-2', 200), + makeInvoice('inv-3', 50), + ]); + const result = computeIncludedTotal(report, ['inv-1', 'inv-3'], new Set()); + expect(result).toBe(150); + }); + + // Scenario 4: excluded budget lines reduce contribution + it('subtracts allocatedPortion of excluded lines from the included invoice contribution', () => { + const invoice = makeInvoice('inv-1', 300, [ + { id: 'line-a', allocatedPortion: 80 }, + { id: 'line-b', allocatedPortion: 40 }, + ]); + const report = makeReport([invoice]); + // Exclude line-a only → contribution = 300 - 80 = 220 + const result = computeIncludedTotal(report, ['inv-1'], new Set(['line-a'])); + expect(result).toBe(220); + }); + + it('subtracts multiple excluded lines from a single invoice', () => { + const invoice = makeInvoice('inv-1', 500, [ + { id: 'line-x', allocatedPortion: 100 }, + { id: 'line-y', allocatedPortion: 150 }, + ]); + const report = makeReport([invoice]); + // Exclude both → 500 - 100 - 150 = 250 + const result = computeIncludedTotal(report, ['inv-1'], new Set(['line-x', 'line-y'])); + expect(result).toBe(250); + }); + + // Scenario 5: per-invoice defensive rounding (>2dp input) + it('rounds per-invoice contribution to 2dp when allocatedAmount has more than 2dp', () => { + // 100.12345 → Math.round(100.12345 * 100) / 100 = 100.12 + const invoice = makeInvoice('inv-1', 100.12345); + const report = makeReport([invoice]); + const result = computeIncludedTotal(report, ['inv-1'], new Set()); + expect(result).toBe(100.12); + }); + + it('rounds per-invoice contribution after subtracting excluded line portions', () => { + // allocatedAmount = 100.12345, excluded line portion = 10.00345 + // contribution before rounding = 100.12345 - 10.00345 = 90.12 + // Math.round(90.12 * 100) / 100 = 90.12 + const invoice = makeInvoice('inv-1', 100.12345, [{ id: 'line-a', allocatedPortion: 10.00345 }]); + const report = makeReport([invoice]); + const result = computeIncludedTotal(report, ['inv-1'], new Set(['line-a'])); + expect(result).toBe(90.12); + }); + + // Scenario 6: final total rounding handles floating-point imprecision + it('rounds the final total to 2dp when per-invoice contributions produce floating-point drift', () => { + // 1.1 + 2.2 = 3.3000000000000003 in JS (classic float drift) + // computeIncludedTotal must return 3.3, not 3.3000000000000003 + const report = makeReport([makeInvoice('inv-1', 1.1), makeInvoice('inv-2', 2.2)]); + const result = computeIncludedTotal(report, ['inv-1', 'inv-2'], new Set()); + expect(result).toBe(3.3); + }); + + // Scenario 7: excluded line on a non-included invoice has no effect + it('ignores excluded lines whose parent invoice is not in includedInvoiceIds', () => { + // inv-1 is included, inv-2 is NOT included. line-b belongs to inv-2. + // Excluding line-b must have no effect; result = allocatedAmount of inv-1 only. + const report = makeReport([ + makeInvoice('inv-1', 400), + makeInvoice('inv-2', 600, [{ id: 'line-b', allocatedPortion: 200 }]), + ]); + const result = computeIncludedTotal(report, ['inv-1'], new Set(['line-b'])); + expect(result).toBe(400); + }); + + // Scenario 8: empty excludedLineIds → equivalent to no exclusions + it('returns the same total with an empty excludedLineIds Set as with no exclusions', () => { + const invoice = makeInvoice('inv-1', 250, [{ id: 'line-c', allocatedPortion: 50 }]); + const report = makeReport([invoice]); + const withoutExclusions = computeIncludedTotal(report, ['inv-1'], new Set()); + // No lines excluded → full allocatedAmount + expect(withoutExclusions).toBe(250); + }); + + // Additional correctness guards + it('returns 0 when includedInvoiceIds references IDs not present in the report', () => { + const report = makeReport([makeInvoice('inv-1', 100)]); + const result = computeIncludedTotal(report, ['inv-does-not-exist'], new Set()); + expect(result).toBe(0); + }); + + it('handles a report with an empty invoices array', () => { + const report = makeReport([]); + const result = computeIncludedTotal(report, ['inv-1'], new Set()); + expect(result).toBe(0); + }); + + it('handles allocatedAmount of 0 for an included invoice', () => { + const report = makeReport([makeInvoice('inv-1', 0)]); + const result = computeIncludedTotal(report, ['inv-1'], new Set()); + expect(result).toBe(0); + }); +}); diff --git a/shared/src/lib/reportMath.ts b/shared/src/lib/reportMath.ts new file mode 100644 index 000000000..0cd8fe967 --- /dev/null +++ b/shared/src/lib/reportMath.ts @@ -0,0 +1,37 @@ +/** + * Shared report math utilities used by both server and client. + */ + +import type { SourceReportResponse } from '../types/sourceReport.js'; + +/** + * Computes the total allocated amount for a set of included invoices, + * subtracting any excluded budget-line portions, with per-invoice defensive + * rounding to 2 dp. + * + * @param report The source report (server: unadjusted; client: post-applyLineExclusions) + * @param includedInvoiceIds Invoice IDs to sum (only these invoices are included) + * @param excludedLineIds Budget-line IDs whose allocatedPortion is subtracted + * @returns Total amount rounded to 2 dp + */ +export function computeIncludedTotal( + report: SourceReportResponse, + includedInvoiceIds: string[], + excludedLineIds: Set, +): number { + const includedSet = new Set(includedInvoiceIds); + let total = 0; + for (const inv of report.invoices) { + if (!includedSet.has(inv.invoiceId)) continue; + let contribution = inv.allocatedAmount; + for (const line of inv.budgetLines) { + if (excludedLineIds.has(line.id)) { + contribution -= line.allocatedPortion; + } + } + // Defensive rounding: guards against >2dp floating-point inputs + contribution = Math.round(contribution * 100) / 100; + total += contribution; + } + return Math.round(total * 100) / 100; +} diff --git a/shared/src/types/config.ts b/shared/src/types/config.ts index ddb8569a9..1d2b3c367 100644 --- a/shared/src/types/config.ts +++ b/shared/src/types/config.ts @@ -7,7 +7,10 @@ export interface AppConfigResponse { currency: string; /** VAT/sales-tax rate as a fraction (e.g. 0.19 = 19%) configured via VAT_RATE env var. Default: 0.19. */ vatRate: number; - /** Whether LLM auto-itemization is enabled (all LLM env vars are set). */ + /** + * Whether LLM auto-itemization is enabled (all LLM env vars are set). + * @deprecated Use llmEnabled instead. + */ autoItemizeEnabled: boolean; /** Alias for autoItemizeEnabled — clearer name for LLM capabilities. Story #1901. */ llmEnabled: boolean; diff --git a/shared/src/types/sourceReport.ts b/shared/src/types/sourceReport.ts index d7805aae2..8098683ed 100644 --- a/shared/src/types/sourceReport.ts +++ b/shared/src/types/sourceReport.ts @@ -57,7 +57,7 @@ export interface SourceReportInvoice { /** True iff the invoice's funding spans 2+ distinct budget sources across budget lines and tagged deposits. */ isSplit: boolean; documents: SourceReportDocument[]; - /** Budget line subtraction rows: all ibl lines per invoice (even portion 0). Deposit-only invoices: []. */ + /** Budget lines allocated to this invoice for the requested source only. Other sources' lines are absent (not present with zero portion). Used as a subtraction basis for line-exclusion math. */ budgetLines: SourceReportBudgetLine[]; /** Deposit rows: all deposits for this invoice, filtered to untagged-or-this-source only. */ deposits: SourceReportDeposit[]; diff --git a/wiki b/wiki index 5c1c7e71a..d532adad8 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 5c1c7e71a6981116147c5c1b3b365cf491d1c353 +Subproject commit d532adad8467a103c68ce4b53036df7d1e99a333 From c1b4f5bd93c5a6b6ae36b66bb6f2303d76fba76c Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 4 Aug 2026 18:05:13 +0200 Subject: [PATCH 19/42] feat(subsidies): add No Category option to applicable categories - Adds "No Category" as a selectable option in subsidy applicable categories - Includes subsidy service factory updates and test fixtures for `includesNoCategoryItems` - Fixes E2E smoke test mock missing `claimable`/`quotationCoveredByDeposits` fields - Rebased onto latest beta (includes Claimable tile, auth rate limits, etc.) Fixes #1983 Co-Authored-By: Claude backend-developer Co-Authored-By: Claude e2e-test-engineer Co-Authored-By: Claude frontend-developer Co-Authored-By: Claude qa-integration-tester Co-Authored-By: Claude translator --- client/src/App.test.tsx | 2 + .../InvoicePipelineCard.test.tsx | 2 + .../SourceBudgetLinePanel.tsx | 4 +- .../SubsidyPipelineCard.test.tsx | 1 + .../budget/InvoiceLinkModal.test.tsx | 2 + .../budget/SubsidyLinkSection.test.tsx | 1 + client/src/i18n/de/budget.json | 4 + client/src/i18n/en/budget.json | 4 + .../src/lib/householdItemSubsidiesApi.test.ts | 3 + client/src/lib/invoicesApi.test.ts | 2 + client/src/lib/subsidyProgramsApi.test.ts | 2 + client/src/lib/workItemsApi.test.ts | 1 + .../BudgetSourcesPage.module.css | 5 +- .../DashboardPage/DashboardPage.test.tsx | 2 + .../InvoicesPage/InvoicesPage.module.css | 6 + .../pages/InvoicesPage/InvoicesPage.test.tsx | 20 +++- .../src/pages/InvoicesPage/InvoicesPage.tsx | 16 ++- .../SubsidyProgramsPage.module.css | 5 +- .../SubsidyProgramsPage.test.tsx | 3 + .../SubsidyProgramsPage.tsx | 89 +++++++++++++-- e2e/pages/InvoicesPage.ts | 19 +-- e2e/tests/invoices/invoices-overdue.spec.ts | 2 +- e2e/tests/invoices/invoices.spec.ts | 5 +- .../migrations/0045_subsidy_no_category.sql | 1 + server/src/db/schema.ts | 1 + server/src/routes/subsidyPrograms.ts | 2 + server/src/services/budgetBreakdownService.ts | 20 +++- server/src/services/budgetOverviewService.ts | 30 +++-- server/src/services/invoiceService.ts | 13 ++- .../services/shared/depositAggregateUtils.ts | 108 ++++++++++++++++++ .../shared/subsidyCalculationEngine.test.ts | 1 + .../shared/subsidyCalculationEngine.ts | 5 +- .../shared/subsidyPaybackServiceFactory.ts | 11 +- .../services/shared/subsidyServiceFactory.ts | 1 + server/src/services/subsidyProgramService.ts | 9 +- shared/src/types/invoice.ts | 4 + shared/src/types/subsidyProgram.ts | 9 +- 37 files changed, 355 insertions(+), 60 deletions(-) create mode 100644 server/src/db/migrations/0045_subsidy_no_category.sql diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 94200f091..262781eeb 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -334,6 +334,8 @@ describe('App', () => { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }, }); mockFetchWorkItemBudgets.mockResolvedValue([]); diff --git a/client/src/components/InvoicePipelineCard/InvoicePipelineCard.test.tsx b/client/src/components/InvoicePipelineCard/InvoicePipelineCard.test.tsx index e3c5a6222..4975e0c3c 100644 --- a/client/src/components/InvoicePipelineCard/InvoicePipelineCard.test.tsx +++ b/client/src/components/InvoicePipelineCard/InvoicePipelineCard.test.tsx @@ -52,6 +52,8 @@ const baseSummary: InvoiceStatusBreakdown = { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }; const baseInvoice: Invoice = { diff --git a/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx b/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx index 1a092e194..14881a707 100644 --- a/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx +++ b/client/src/components/SourceBudgetLinePanel/SourceBudgetLinePanel.tsx @@ -179,7 +179,9 @@ function buildAreaTree(lines: BudgetSourceBudgetLine[]): AreaNode[] { }; } - const childAreaIds = parentMap.get(areaId) ?? []; + // areaId === null is the unassigned bucket; parentMap.get(null) would return all named + // root areas (their parent key is null), so guard explicitly to avoid duplicating them. + const childAreaIds = areaId !== null ? (parentMap.get(areaId) ?? []) : []; const children = childAreaIds .sort((a, b) => areaMap.get(a)!.name.localeCompare(areaMap.get(b)!.name)) .map((childId) => buildNode(childId, depth + 1)); diff --git a/client/src/components/SubsidyPipelineCard/SubsidyPipelineCard.test.tsx b/client/src/components/SubsidyPipelineCard/SubsidyPipelineCard.test.tsx index fcad23761..944a073cd 100644 --- a/client/src/components/SubsidyPipelineCard/SubsidyPipelineCard.test.tsx +++ b/client/src/components/SubsidyPipelineCard/SubsidyPipelineCard.test.tsx @@ -58,6 +58,7 @@ const baseProgram: SubsidyProgram = { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/client/src/components/budget/InvoiceLinkModal.test.tsx b/client/src/components/budget/InvoiceLinkModal.test.tsx index 1826495ed..1e303601e 100644 --- a/client/src/components/budget/InvoiceLinkModal.test.tsx +++ b/client/src/components/budget/InvoiceLinkModal.test.tsx @@ -125,6 +125,8 @@ function buildPaginatedResponse(invoices: Invoice[]): InvoiceListPaginatedRespon claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: invoices.length, totalAmount: 1000 }, + quotationCoveredByDeposits: 0, }, }; } diff --git a/client/src/components/budget/SubsidyLinkSection.test.tsx b/client/src/components/budget/SubsidyLinkSection.test.tsx index 3edeb5c45..39532582e 100644 --- a/client/src/components/budget/SubsidyLinkSection.test.tsx +++ b/client/src/components/budget/SubsidyLinkSection.test.tsx @@ -50,6 +50,7 @@ function makeSubsidy(overrides?: Partial): SubsidyProgram { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '', updatedAt: '', diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json index 834e9fc5d..e65bdd528 100644 --- a/client/src/i18n/de/budget.json +++ b/client/src/i18n/de/budget.json @@ -713,6 +713,7 @@ "eligibility": "Anforderungen", "notes": "Notizen", "applicableCategories": "Anwendbare Budgetkategorien", + "noCategoryLabel": "Ohne Kategorie", "selectAll": "Alle Auswählen", "deselectAll": "Alle Abwählen", "required": "*", @@ -889,6 +890,9 @@ "summaryPaid": "Bezahlt", "summaryClaimed": "Eingereicht", "summaryQuotation": "Angebot", + "summaryQuotationCovered": "{{amount}} bereits hinterlegt", + "summaryClaimable": "Einreichbar", + "summaryClaimableHint": "Ohne Eigenanteil", "summaryOverdue": "Überfällig", "summaryOverdueWarning_one": "{{count}} ausstehende Rechnung überfällig", "summaryOverdueWarning_other": "{{count}} ausstehende Rechnungen überfällig", diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json index b304e26f0..393400fde 100644 --- a/client/src/i18n/en/budget.json +++ b/client/src/i18n/en/budget.json @@ -469,6 +469,9 @@ "summaryPaid": "Paid", "summaryClaimed": "Claimed", "summaryQuotation": "Quotation", + "summaryQuotationCovered": "{{amount}} already deposited", + "summaryClaimable": "Claimable", + "summaryClaimableHint": "Excl. discretionary sources", "summaryOverdue": "Overdue", "summaryOverdueWarning_one": "{{count}} pending invoice past due", "summaryOverdueWarning_other": "{{count}} pending invoices past due", @@ -833,6 +836,7 @@ "eligibility": "Eligibility Requirements", "notes": "Notes", "applicableCategories": "Applicable Budget Categories", + "noCategoryLabel": "No Category", "selectAll": "Select All", "deselectAll": "Deselect All", "required": "*", diff --git a/client/src/lib/householdItemSubsidiesApi.test.ts b/client/src/lib/householdItemSubsidiesApi.test.ts index 1000238b2..7d5a6c0fa 100644 --- a/client/src/lib/householdItemSubsidiesApi.test.ts +++ b/client/src/lib/householdItemSubsidiesApi.test.ts @@ -51,6 +51,7 @@ describe('householdItemSubsidiesApi', () => { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -115,6 +116,7 @@ describe('householdItemSubsidiesApi', () => { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -150,6 +152,7 @@ describe('householdItemSubsidiesApi', () => { notes: 'Must apply by end of year', maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/client/src/lib/invoicesApi.test.ts b/client/src/lib/invoicesApi.test.ts index 789002118..f5154dc28 100644 --- a/client/src/lib/invoicesApi.test.ts +++ b/client/src/lib/invoicesApi.test.ts @@ -474,6 +474,8 @@ describe('invoicesApi', () => { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 1, totalAmount: 2500.0 }, + quotationCoveredByDeposits: 0, }, }; diff --git a/client/src/lib/subsidyProgramsApi.test.ts b/client/src/lib/subsidyProgramsApi.test.ts index 06a46d758..9ce8fbd19 100644 --- a/client/src/lib/subsidyProgramsApi.test.ts +++ b/client/src/lib/subsidyProgramsApi.test.ts @@ -27,6 +27,7 @@ describe('subsidyProgramsApi', () => { notes: 'Apply early', maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -84,6 +85,7 @@ describe('subsidyProgramsApi', () => { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', diff --git a/client/src/lib/workItemsApi.test.ts b/client/src/lib/workItemsApi.test.ts index 19fc8d12a..f29c2a6d8 100644 --- a/client/src/lib/workItemsApi.test.ts +++ b/client/src/lib/workItemsApi.test.ts @@ -575,6 +575,7 @@ describe('workItemsApi', () => { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css index 4134f439c..7b5899b7b 100644 --- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css +++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css @@ -484,8 +484,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: var(--spacing-8); - height: var(--spacing-8); + width: 44px; + height: 44px; padding: 0; background: none; border: 1px solid var(--color-border); @@ -979,6 +979,7 @@ .editButton, .deleteButton, .expandToggle, + .docsToggle, .confirmDeleteButton { min-height: 44px; } diff --git a/client/src/pages/DashboardPage/DashboardPage.test.tsx b/client/src/pages/DashboardPage/DashboardPage.test.tsx index 37ce14a23..125019684 100644 --- a/client/src/pages/DashboardPage/DashboardPage.test.tsx +++ b/client/src/pages/DashboardPage/DashboardPage.test.tsx @@ -162,6 +162,8 @@ const emptyInvoicesResponse: InvoiceListPaginatedResponse = { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }, }; diff --git a/client/src/pages/InvoicesPage/InvoicesPage.module.css b/client/src/pages/InvoicesPage/InvoicesPage.module.css index 154648037..37e16e58e 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.module.css +++ b/client/src/pages/InvoicesPage/InvoicesPage.module.css @@ -66,6 +66,12 @@ color: var(--color-success-badge-text); } +.summaryHint { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + margin-top: var(--spacing-1); +} + .summaryCardOverdue { background: var(--color-warning-bg); border: 1px solid var(--color-warning); diff --git a/client/src/pages/InvoicesPage/InvoicesPage.test.tsx b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx index a14e45336..fb9e6b265 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.test.tsx +++ b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx @@ -202,6 +202,8 @@ const emptySummary = { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }; const populatedSummary = { @@ -210,6 +212,8 @@ const populatedSummary = { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 1, totalAmount: 15000 }, + quotationCoveredByDeposits: 0, }; const emptyResponse: InvoiceListPaginatedResponse = { @@ -726,6 +730,8 @@ describe('InvoicesPage', () => { claimed: { count: 3, totalAmount: 900 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 1, totalAmount: 15000 }, + quotationCoveredByDeposits: 0, }, }; mockFetchAllInvoices.mockResolvedValueOnce(responseWithClaimed); @@ -750,6 +756,8 @@ describe('InvoicesPage', () => { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }, }; mockFetchAllInvoices.mockResolvedValueOnce(responseWithZeroClaimed); @@ -763,7 +771,7 @@ describe('InvoicesPage', () => { expect(screen.getAllByText('Claimed').length).toBeGreaterThan(0); }); - it('Paid card shows only paid summary data (not combined with claimed)', async () => { + it('Claimable card shows only claimable summary data (not combined with claimed)', async () => { const responseWithSeparateData: InvoiceListPaginatedResponse = { ...populatedResponse, summary: { @@ -772,6 +780,8 @@ describe('InvoicesPage', () => { claimed: { count: 3, totalAmount: 900 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 2, totalAmount: 12000 }, + quotationCoveredByDeposits: 0, }, }; mockFetchAllInvoices.mockResolvedValueOnce(responseWithSeparateData); @@ -786,12 +796,12 @@ describe('InvoicesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getAllByText('Paid').length).toBeGreaterThan(0); + expect(screen.getAllByText('Claimable').length).toBeGreaterThan(0); expect(screen.getAllByText('Claimed').length).toBeGreaterThan(0); }); - // Paid total is €5,000 and claimed total is €900 — they must appear as separate amounts - expect(screen.getByText(fmtCurrency(5000))).toBeInTheDocument(); + // Claimable total is €12,000 and claimed total is €900 — they must appear as separate amounts + expect(screen.getByText(fmtCurrency(12000))).toBeInTheDocument(); expect(screen.getByText(fmtCurrency(900))).toBeInTheDocument(); }); }); @@ -1252,6 +1262,8 @@ describe('InvoicesPage', () => { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 1, totalAmount: 10000 }, + quotationCoveredByDeposits: 0, }, }; diff --git a/client/src/pages/InvoicesPage/InvoicesPage.tsx b/client/src/pages/InvoicesPage/InvoicesPage.tsx index 8a11dcf27..5fc1a1dff 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.tsx +++ b/client/src/pages/InvoicesPage/InvoicesPage.tsx @@ -76,6 +76,8 @@ export function InvoicesPage() { claimed: { count: 0, totalAmount: 0 }, quotation: { count: 0, totalAmount: 0 }, overdue: { count: 0, totalAmount: 0 }, + claimable: { count: 0, totalAmount: 0 }, + quotationCoveredByDeposits: 0, }); const [filterMeta, setFilterMeta] = useState({}); const [isLoading, setIsLoading] = useState(true); @@ -531,11 +533,12 @@ export function InvoicesPage() { {formatCurrency(summary.pending.totalAmount)}
    - {t('invoices.summaryPaid')} - {summary.paid.count} + {t('invoices.summaryClaimable')} + {summary.claimable.count} - {formatCurrency(summary.paid.totalAmount)} + {formatCurrency(summary.claimable.totalAmount)} + {t('invoices.summaryClaimableHint')}
    {t('invoices.summaryClaimed')} @@ -550,6 +553,13 @@ export function InvoicesPage() { {formatCurrency(summary.quotation.totalAmount)} + {summary.quotationCoveredByDeposits > 0 && ( + + {t('invoices.summaryQuotationCovered', { + amount: formatCurrency(summary.quotationCoveredByDeposits), + })} + + )}
    {hasOverdue && (
    { notes: 'Apply early', maximumAmount: null, applicableCategories: [sampleCategory1], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', @@ -198,6 +199,7 @@ describe('SubsidyProgramsPage', () => { notes: null, maximumAmount: null, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-02T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', @@ -215,6 +217,7 @@ describe('SubsidyProgramsPage', () => { notes: null, maximumAmount: 10000, applicableCategories: [], + includesNoCategoryItems: false, createdBy: null, createdAt: '2026-01-03T00:00:00.000Z', updatedAt: '2026-01-03T00:00:00.000Z', diff --git a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx index 7c2752f09..9a2751b04 100644 --- a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx +++ b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx @@ -67,6 +67,7 @@ type EditingProgram = { notes: string; categoryIds: string[]; maximumAmount: string; + includesNoCategoryItems: boolean; }; function programToEditState(program: SubsidyProgram): EditingProgram { @@ -84,6 +85,7 @@ function programToEditState(program: SubsidyProgram): EditingProgram { notes: program.notes ?? '', categoryIds: program.applicableCategories.map((c) => c.id), maximumAmount: program.maximumAmount != null ? String(program.maximumAmount) : '', + includesNoCategoryItems: program.includesNoCategoryItems, }; } @@ -113,6 +115,7 @@ export function SubsidyProgramsPage() { const [newNotes, setNewNotes] = useState(''); const [newMaximumAmount, setNewMaximumAmount] = useState(''); const [newCategoryIds, setNewCategoryIds] = useState([]); + const [newIncludesNoCategoryItems, setNewIncludesNoCategoryItems] = useState(false); const [isCreating, setIsCreating] = useState(false); const [createError, setCreateError] = useState(''); @@ -173,8 +176,9 @@ export function SubsidyProgramsPage() { setNewApplicationDeadline(''); setNewNotes(''); setNewMaximumAmount(''); - // Default to all categories selected + // Default to all categories selected (including "No Category") setNewCategoryIds(allCategories.map((c) => c.id)); + setNewIncludesNoCategoryItems(true); setCreateError(''); }; @@ -185,19 +189,30 @@ export function SubsidyProgramsPage() { }; const handleToggleAllNew = () => { - if (newCategoryIds.length === allCategories.length) { + const allSelected = + newCategoryIds.length === allCategories.length && newIncludesNoCategoryItems; + if (allSelected) { setNewCategoryIds([]); + setNewIncludesNoCategoryItems(false); } else { setNewCategoryIds(allCategories.map((c) => c.id)); + setNewIncludesNoCategoryItems(true); } }; const handleToggleAllEdit = () => { if (!editingProgram) return; - if (editingProgram.categoryIds.length === allCategories.length) { - setEditingProgram({ ...editingProgram, categoryIds: [] }); + const allSelected = + editingProgram.categoryIds.length === allCategories.length && + editingProgram.includesNoCategoryItems; + if (allSelected) { + setEditingProgram({ ...editingProgram, categoryIds: [], includesNoCategoryItems: false }); } else { - setEditingProgram({ ...editingProgram, categoryIds: allCategories.map((c) => c.id) }); + setEditingProgram({ + ...editingProgram, + categoryIds: allCategories.map((c) => c.id), + includesNoCategoryItems: true, + }); } }; @@ -247,6 +262,7 @@ export function SubsidyProgramsPage() { notes: newNotes.trim() || null, maximumAmount: newMaximumAmount.trim() ? parseFloat(newMaximumAmount) : null, categoryIds: newCategoryIds, + includesNoCategoryItems: newIncludesNoCategoryItems, }); setPrograms([...programs, created]); resetCreateForm(); @@ -314,6 +330,7 @@ export function SubsidyProgramsPage() { ? parseFloat(editingProgram.maximumAmount) : null, categoryIds: editingProgram.categoryIds, + includesNoCategoryItems: editingProgram.includesNoCategoryItems, }); setPrograms(programs.map((p) => (p.id === updated.id ? updated : p))); setEditingProgram(null); @@ -414,6 +431,7 @@ export function SubsidyProgramsPage() { setShowCreateForm(true); setCreateError(''); setNewCategoryIds(allCategories.map((c) => c.id)); + setNewIncludesNoCategoryItems(true); }} disabled={showCreateForm} > @@ -635,12 +653,31 @@ export function SubsidyProgramsPage() { onClick={handleToggleAllNew} disabled={isCreating} > - {newCategoryIds.length === allCategories.length + {newCategoryIds.length === allCategories.length && newIncludesNoCategoryItems ? t('subsidies.form.deselectAll') : t('subsidies.form.selectAll')}
    + {allCategories.map((category) => (
    + {allCategories.map((category) => (
    ` only — so the desktop `` (statusText, splitNote, depositReducedNote, refundNote, +deposit badge) and the _entire_ mobile card tree lost coverage. Net worse than round 2: it traded a +minor over-tag (English chrome read with German rules) for a larger under-tag (German data read with +English rules), and below the 767px breakpoint `.table { display: none }` means zero coverage. +**Why:** an "over-tagging" finding asks you to _relocate or except_ the tag, never to drop it. The +HTML idiom for a nested language exception is **counter-tagging** the inner chrome (`lang={uiLang}` +on the reset button + sr-only hint), not removing the outer boundary. +**How to apply:** when a review round removes an attribute/wrapper, diff the set of leaf nodes that +_were_ covered against those that _are_ covered and demand the delta be re-covered. Two specific +traps here: (a) responsive CSS-only duplicate trees — a fix applied to the desktop table silently +leaves the mobile card list uncovered, and mobile/tablet Playwright projects only run +`@responsive`-tagged tests so E2E won't catch it; (b) a blanket rule like "EditableField labels are +UI chrome" holds only where labels come from `t()` — the mobile usage field's label is +`content.labels.usage`, i.e. report-language, so the rule inverts inside the table region. +Also: the code fix for a round-N finding landing **without an assertion** (the `.readOnlyValue` +`lang` spans) means it can be reverted with every suite green — always ask "what test would fail?" +for each item the author claims to have addressed. + +**Round-3 addendum (#1910, PR #2004) — "the prop landed" is not "the prop is wired".** The fix for a +review finding can introduce a *new* optional prop, unit-test the prop on the leaf component, thread +it through N call sites, and still have zero coverage of the threading: the leaf tests pass the prop +in themselves. Revert test applied at the call-site level (not the component level) is the only thing +that catches it — delete the `foo={foo}` lines, not the `foo` implementation, and see what goes red. +Optional props make this silent because removing them from JSX is type-legal. + +Companion trap: **a redundant tag that a test asserts.** After restoring an ancestor tag, the +descendant tag it duplicates becomes redundant, and if a test asserts *both* the redundancy is +locked in. The danger is not the duplication, it is that a later cleanup reads the pair as an error +and removes the ancestor — reintroducing the original finding. Ask for a comment naming the +duplication as deliberate. + +Third: **an `aria-label` cannot be language-tagged.** When an element's accessible name comes from +`aria-label` but its content is in another language, no `lang` placement fixes both — the name is +computed on the element that carries the `lang`. The only exact fix is a visually-hidden span with +its own `lang` plus `aria-labelledby`. Worth naming as a known limit rather than looping on it. + +**Round-4 addendum (#1910, PR #2004) — a positive anchor only pins the call sites the fixture +actually renders.** The round-3 fix for "all 8 `uiLang={uiLang}` props could be deleted with every +suite green" was a test asserting `button[lang="en"]` count `>= 1` plus an all-must-match loop. It +does close the *stated* gap (deleting all 8 fails), and the handoff claimed "removing **any** prop +fails" — but per-site mutation testing showed **1 of 8** pinned. The fixture put exactly one field +(`coverLetter.sender`) into edited state, so exactly one reset button ever rendered, so the anchor +could only ever cover that one site; the other 7 still delete silently. + +Generalises well beyond `lang`: **`count >= 1` + "all matches satisfy P" is a per-instance assertion +masquerading as a coverage assertion.** It pins the instances the fixture happens to produce, and the +count floor hides how few that is. When N call sites thread a prop, the discriminating shape is +`expect(matches.length).toBe(N)` with a fixture that forces all N to render — an exact count is the +only version that fails when a site disappears. Two review habits that follow: + +- Never accept "removing any X fails" on the strength of an all-at-once revert. Revert each site + **individually** — the all-at-once test passing tells you nothing about per-site coverage. +- When a fix is partial, say which fraction is pinned. "M1 resolved" and "1 of 8 sites pinned" get + recorded very differently, and the second is what stops the gap being re-found in three months. + +Related smell confirmed the same round: a **near-vacuous negative guard** (`button[lang="de"] === 0` +when no button can ever receive `lang={lang}`) is still worth keeping if it pins a *contract* on +another component ("chrome is always `uiLang`") rather than restating the positive assertion. diff --git a/.claude/agent-memory/product-architect/story-reviews.md b/.claude/agent-memory/product-architect/story-reviews.md index 4ae0b2b12..e6292961b 100644 --- a/.claude/agent-memory/product-architect/story-reviews.md +++ b/.claude/agent-memory/product-architect/story-reviews.md @@ -793,7 +793,7 @@ H1 properly fixed, verified by re-running the revert myself rather than reading plus a `realRender` case that bypasses the helper and reads the rendered doc's run array. The relaxed invariant keeps the two load-bearing properties (contiguous, tail-anchored) with distinct error messages. -M1 (`as Content`) resolved as *unnecessary*, not merely deferrable: removing it type-checks clean, proven +M1 (`as Content`) resolved as _unnecessary_, not merely deferrable: removing it type-checks clean, proven with a tsc positive control (the client project carries ~63 pre-existing stale-`shared` errors, so "tsc is clean" was not available as a signal). Left in place as non-blocking. @@ -801,7 +801,7 @@ M2 (new, non-blocking, pre-existing): ADR-034 rule #1 `max(horizontalRatio) <= 1 `client/`, so this pipeline verifies overflow fixes by mechanism (`wordBreak` present) not outcome. Filed **issue #2003** (tech-debt / should-have / backlog) and took ownership, since it's my ADR text setting the bar. -Also checked and cleared: the fix's *vertical* axis (break-all adds wrapped lines → `dontBreakRows` +Also checked and cleared: the fix's _vertical_ axis (break-all adds wrapped lines → `dontBreakRows` silent-drop hazard) — `packUsageCellRows`' character budget already assumes worst-case per-line counts, so the bound is not weakened. And confirmed no consumer of the old single-grey-run invariant exists in production, `e2e/`, or any wiki page → no wiki update owed. prettier + eslint clean on all three files. @@ -809,3 +809,83 @@ production, `e2e/`, or any wiki page → no wiki update owed. prettier + eslint Method note: this worktree's HEAD already contained the PR head with byte-identical `client/src/lib`, so the revert test ran in place with no extra worktree or `npm install`. Check `git merge-base HEAD ` plus a scoped `git diff --stat` before paying for isolation. + +## PR #2004 — #1910 preview `lang` attribute + #1888 attachments note (2026-08-05, CHANGES_REQUIRED) + +- **H1 blocking**: container-level `lang` + partial counter-tagging left UI-locale labels/buttons + mis-tagged → see recurring-patterns "Broad-scope attribute + partial counter-tagging". +- M1 coupled `lang`/`uiLang` prop pair; M2 vacuous negative test via earlier `EmptyState` early return; + L: en/de terminal-punctuation mismatch in a new key pair, `sourceReports.attachmentsNote` collides by + concept with `editable.attachmentsNoteLabel` + `table.attachmentsNote_one/_other`, + `[class*="container"]` POM selector, `styles.step4Body` is step **5**'s wrapper (pre-existing misnomer). +- Verified fine: `SourceReportType` union exactly matches the three `sourceReports.useCase.*` keys in both + locales (dynamic `t()` key is exhaustive); no client-side document filtering added (#1930 AC7 intact). +- Could not `gh pr review` (own PR) → posted via `gh pr comment`. +- E2E: new Scenarios 25/26/27 pass; Shard 8's 3 failures are pre-existing + `navigation/dashboard.spec.ts` Scenario 13 (#1735) — unrelated. + +### PR #2004 round 2 — Option A H1 fix (2026-08-05, CHANGES_REQUIRED again) + +- H1 correctly fixed via Option A (surgical positive tagging, `uiLang` deleted). Design is clean: + every `lang`-bearing element's own text is `content.*` (report language); all `t()` chrome is outside. +- **New blocking H1-r2**: E2E Scenario 25's assertion still expects container `lang="de"` (comment was + updated, assertion was not) → Shard 2/16 red, confirmed via shard-diff vs `2744d75b`. + See recurring-patterns "Comment refreshed, assertion left behind". +- **H2-r2**: Scenario 27 became unconditional (see "Inverting a contract can make an existing negative + test unconditional"). +- M: `.readOnlyValue` spans (`dateLine`, `closing`) missed by the tagging; no test proves + `ReportContentEditor` passes `lang` to its `EditableField`s (delete all 8 props → still green); + 4 of 5 tagged sections unasserted. +- L: POM `reportContentContainer()` docstring now describes the removed behaviour; `aria-label` in UI + locale inside a `lang`-tagged `` is an inherent, accepted residual — leave a code comment so + nobody "fixes" it by deleting the attribute; `expect(tableWrapper).toBeVisible()` would fail if + Scenario 26 were ever tagged `@responsive` (≤767px hides `.table`, not `.tableWrapper`). +- Playwright projects: `tablet` and `mobile` both `grep: /@responsive/` — untagged scenarios are + **desktop-only**. Useful when judging whether a viewport-sensitive assertion is actually at risk. + +### PR #2004 round 3 — thead retarget (2026-08-05, CHANGES_REQUIRED, 3rd round) + +- **Both round-2 blockers CLEARED**: E2E 25/26/27 now target `` with real assertions + (`'de'` / `null`); `` is unique in the component (`.summaryTable` has no `thead`), so + `.first()` is unambiguous. `toBeVisible()` on `` is only safe because 25-27 are untagged → + desktop-only. M2-r2 (integration test on the usage input, scoped via `getDesktopTable` + + `getByDisplayValue`), M3-r2 (double guard), L1-r2 (POM docstring, verified line-by-line) all fixed. + `uiLang` is now 0 hits repo-wide. +- **New blocking H1-r3**: removing `lang` from `.tableWrapper`/`.mobileCardList` and re-adding it to + `` only dropped coverage for the desktop `` and the whole mobile card tree — + AC1 of #1910 explicitly enumerates "table captions … status text". Recommended fix: restore the + wrapper tags and counter-tag `EditableField`'s sr-only hint + reset button via a new `uiLang` prop + (do NOT counter-tag `` is now redundant with the wrapper tag and a test asserts both — a future + cleanup could delete the *wrapper* instead and reintroduce H5. +- E2E scenarios 25-27 are untagged, therefore desktop-only (`e2e/playwright.config.ts` gates + tablet/mobile on `grep: /@responsive/`), so their `expect(thead).toBeVisible()` is safe despite + `.table { display: none }` at <=767px. Consequence: the mobile fix has unit coverage only. +- CI: `Quality Gates` green. `E2E Tests (Shard 8/16)` fails on `navigation/dashboard.spec.ts` + 1130/1164/1192 (#1735 Add dropdown) on **all four** head commits including the first -> pre-existing, + main-only, needs its own issue before the next promotion. diff --git a/.claude/agent-memory/product-owner/MEMORY.md b/.claude/agent-memory/product-owner/MEMORY.md index 923e6f072..28e49c7f8 100644 --- a/.claude/agent-memory/product-owner/MEMORY.md +++ b/.claude/agent-memory/product-owner/MEMORY.md @@ -40,9 +40,11 @@ Full detail in [standalone-bugs-and-stories.md](standalone-bugs-and-stories.md) - Photo: #1723 lightbox picker UX (2026-06-16) - **DataTable: #1955** two-column toggle race silently hides 2nd column, all 6 DataTable pages (Should Have, S, Backlog, 2026-08-02). See [datatable-column-preference-race.md](datatable-column-preference-race.md) — records that **fast clicking is the SAFE case** (I judged this backwards; debounce `clearTimeout` coalesces rapid input, the >500ms reading-pace gap is the reachable one) and that #1920's E2E-only fix (`InvoicesPage.enableColumn()` awaits the PATCH) makes CI green **without** fixing production — don't close #1955 on a green shard. - **#1957** latent cross-file E2E test-isolation hazard (shared-admin `user_preferences` writes under `fullyParallel`, `LocaleContext.syncWithServer` actively flips a victim test's locale) — Should Have, bug, Backlog, 2026-08-02/03, filed from `/fix-e2e` work on PR #1956. Scoped as an audit + per-spec sweep, not a single-file fix — found a second live instance (`diary-uat-fixes.spec.ts` vs `dashboard.spec.ts`, key `dashboard.hiddenCards`) while researching it. Distinct from #1955 (production race) and #1920 (E2E workaround for #1955). Detail in [e2e-shared-admin-preference-hazard.md](e2e-shared-admin-preference-hazard.md). +- **#2005** E2E shard 8/16 red across four beta PRs — dashboard "New Invoice" (Scenario 13, #1735) opens no modal in either Paperless branch, fails on retry too (bug, Must Have, Todo, 2026-08-05, filed from PR #2004 round-5 sign-off). **Blocks the next `beta`→`main` promotion** (`E2E Gates` is main-only). Proved PR-independent by checking the head commits of #1999/#2000/#2002 — not `beta`, which never runs full E2E. AC1 is a *classification* AC so the dev-team-lead keeps the Test Failure Debugging Protocol call. Detail in [bank-report-wizard.md](bank-report-wizard.md) §"round 5". +- **PR #2004 (#1888 + #1910)** — **APPROVED, M1 closed round 5** (`6a3eb7ec`, 2026-08-05): `lang={lang}` on the `` (E2E 25/26/27 target it). Round-2 H2 mirror: stale *comment* over fresh `expect` = flag, not block. #1888 re-verified by diffing its file against the accepted commit (empty). Coordinator AC numbering wrong a **5th** time. Prior rounds: #1910 REJECTED **three times**: round 1 on AC3 (`lang` blanket-tagged on `.container`, only `

    `s counter-tagged → `EditableField` chrome announced in report language, correct before the PR); round 2 on the "Option A surgical tagging" fix (`64c07b8a`) — **H2 AC5 red** (E2E Scenario 25 still asserts `container` lang, only the _comment_ was updated; Shard 2/16 test #168 confirms), **H3 AC1 regression** (`coverLetter.dateLine`/`closing` report-language spans lost coverage when the blanket tag narrowed), **H4 AC3 residue** (`.tableWrapper`/`.mobileCardList` still enclose the reset button + sr-only edited hint). Patterns: **AC enumerating element classes → tick each one off**; **override-by-inheritance fix → "what else inherits from the node you tagged?"**; **behaviour-inverting fix → grep for the OLD assertion, not the old comment**; **blanket→surgical refactor → audit what the blanket was silently covering**; **price intrinsic tensions differently from oversights** (offered a documented deviation for the one-element-one-lang `aria-label` conflict). Round 3 (`04e4ae0c`): H2/H3/H4/M1/L1 all verified fixed (**shard-diff across the PR's own commits** is the cheap proof — Shard 2/16 red→green), rejected again on **H5**: emptying `.tableWrapper`/`.mobileCardList` and reconciling only `

    ` leaves the desktop `` and the **entire** `.mobileCardList` untagged, and CSS hides `.table` at ≤767px → **zero `lang` on the mobile viewport**, AC1 unmet for a whole viewport + net regression vs the prior commit. Lessons: **when demanding a tag be removed, name the replacement coverage in the same breath**; **retract your own misread takeaway** (round 2's `uiLang` deletion was not a ruling against targeted counter-tagging); `npm run lint` has **no Prettier** and CI has no `format:check`, so formatting drift merges silently; `gh pr review` can't request changes on a human-authored PR → verdict goes in a comment. Detail in [bank-report-wizard.md](bank-report-wizard.md) §§"PR #2004 review" + "round 2" + "round 3". - **Bank Report Wizard mini-epic** (no parent epic) — all rulings, contract facts, per-PR review outcomes and filed follow-ups in [bank-report-wizard.md](bank-report-wizard.md). Shipped: #1876→#1877→#1878→#1879, Round 2 #1898–#1901, Round 3 #1929–#1933 (all merged; #1929 took 4 rounds, #1925 closed as duplicate). **Open**: #1888 indicator, #1891 (2 wiki MUST FIX), #1895→#1896/#1897 claim close-out, #1910 `lang` attr, #1917 consolidated follow-ups (incl. `KI` glossary entry + `computeIncludedTotal` extraction), #1937/#1938 PDF header bugs, #1939 geometry hygiene, #1940/#1941/#1950, #1946 in-flight AI generation (Must Have), #1947 `useReducer`, #1952/#1953, #1965–#1972 (PR #1959 sweep), **#1973** column visibility (Should Have, Todo, blocked-by #1965). #1931 merged but **not Done** — ACs 3.2/3.3 need live-LLM UAT. -- **Reusable rulings from this cluster** (detail in [bank-report-wizard.md](bank-report-wizard.md), patterns in [pr-review-patterns.md](pr-review-patterns.md)): **merge is a code gate, Done is an acceptance gate** (unverifiable AC *with* a substitute assertion = documented deviation; *without* one → UAT, reopen on failure); **a finding that defeats the PR's own AC belongs in that PR, not a follow-up**; **closed/released ACs get a dated supersession comment, never a rewrite**; **ACs that misdescribe reality fail correct implementations at UAT** (seen 3×: #1943 AC4, #1933 AC2.1/2.7, my own #1925/#1932 transcription); **comment keeps the rationale, issue owns the guard**; bounded-and-quantified earns a tracked owner, unbounded-and-estimated gets documentation only. -- **#1973 column visibility wired through to the PDF** (user-story, Should Have, Todo, 2026-08-03, **blocked-by #1965**) — user reversed #1959's preview-only hint. **My proposed "at least one of Vendor/Invoice #" floor was rejected as an invented compliance rule**; only Allocated Amount is mandatory (survived because its justification is *structural* — summary amounts + #1959 inline labels live in that cell — not purposive). 96 legal subsets (overview 2^6=64, claim 2^5=32), floor 1 column. Rulings: legend stays **unconditional** (AC 6.1 forbids `if invoiceAmount hidden`) because `(less deposit)` was insufficient regardless of adjacency; base set **IS** the ceiling for a **data** reason (`status: isOverview ? status : null`) — corrected the coordinator's "arbitrary means no ceiling" reading; per-session state, not `useColumnPreferences`; narrower-than-page table when neither Usage nor Vendor visible. **#1966 CLOSED as superseded** (board Wont-Do) — its AC1 would pass while the PDF still contained every column. Recommended **after** the #1958 promotion. **Rev 3 (spec reconciliation)**: adopted the dev-team-lead's **three-tier summary-label fallback** over my "same cell" ruling (92 subsets last-leading-column → Invoice Amount → separate block beneath table for 4; tier 3 *increases* preview parity, `ReportContentEditor.tsx:442-445`); added **AC 3.7 one-sided chunk-budget clamp** (650 scales *down* never *up* — the hazard is a future *added* column narrowing Usage, not this change); 72 subsets = `printableWidth()`, 24 narrower (84.00–315.00pt). **Process failure: I rewrote the body but reported only the rulings, so two agents spec'd from a stale rev 1 and re-filed an already-fixed contradiction — always say "body rewritten, numbering reassigned".** Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1973 column visibility" + §"rev 3". +- **Reusable rulings from this cluster** (detail in [bank-report-wizard.md](bank-report-wizard.md), patterns in [pr-review-patterns.md](pr-review-patterns.md)): **merge is a code gate, Done is an acceptance gate** (unverifiable AC _with_ a substitute assertion = documented deviation; _without_ one → UAT, reopen on failure); **a finding that defeats the PR's own AC belongs in that PR, not a follow-up**; **closed/released ACs get a dated supersession comment, never a rewrite**; **ACs that misdescribe reality fail correct implementations at UAT** (seen 3×: #1943 AC4, #1933 AC2.1/2.7, my own #1925/#1932 transcription); **comment keeps the rationale, issue owns the guard**; bounded-and-quantified earns a tracked owner, unbounded-and-estimated gets documentation only. +- **#1973 column visibility wired through to the PDF** (user-story, Should Have, Todo, 2026-08-03, **blocked-by #1965**) — user reversed #1959's preview-only hint. **My proposed "at least one of Vendor/Invoice #" floor was rejected as an invented compliance rule**; only Allocated Amount is mandatory (survived because its justification is _structural_ — summary amounts + #1959 inline labels live in that cell — not purposive). 96 legal subsets (overview 2^6=64, claim 2^5=32), floor 1 column. Rulings: legend stays **unconditional** (AC 6.1 forbids `if invoiceAmount hidden`) because `(less deposit)` was insufficient regardless of adjacency; base set **IS** the ceiling for a **data** reason (`status: isOverview ? status : null`) — corrected the coordinator's "arbitrary means no ceiling" reading; per-session state, not `useColumnPreferences`; narrower-than-page table when neither Usage nor Vendor visible. **#1966 CLOSED as superseded** (board Wont-Do) — its AC1 would pass while the PDF still contained every column. Recommended **after** the #1958 promotion. **Rev 3 (spec reconciliation)**: adopted the dev-team-lead's **three-tier summary-label fallback** over my "same cell" ruling (92 subsets last-leading-column → Invoice Amount → separate block beneath table for 4; tier 3 _increases_ preview parity, `ReportContentEditor.tsx:442-445`); added **AC 3.7 one-sided chunk-budget clamp** (650 scales _down_ never _up_ — the hazard is a future _added_ column narrowing Usage, not this change); 72 subsets = `printableWidth()`, 24 narrower (84.00–315.00pt). **Process failure: I rewrote the body but reported only the rulings, so two agents spec'd from a stale rev 1 and re-filed an already-fixed contradiction — always say "body rewritten, numbering reassigned".** Detail in [bank-report-wizard.md](bank-report-wizard.md) §"#1973 column visibility" + §"rev 3". ## Requirements Coverage diff --git a/.claude/agent-memory/product-owner/bank-report-wizard.md b/.claude/agent-memory/product-owner/bank-report-wizard.md index efd131110..86cc6aeec 100644 --- a/.claude/agent-memory/product-owner/bank-report-wizard.md +++ b/.claude/agent-memory/product-owner/bank-report-wizard.md @@ -157,76 +157,76 @@ Related: [[pr-review-patterns]]. Source: user inspection of downloaded report PDFs + a wizard walkthrough after #1901 merged. No parent epic; all board **Todo**, destined for `/batch-develop` (one issue = one branch/PR, no dependency chain declared — they touch disjoint files, but #1929 and #1932 both touch `reportPdf/`, so whichever lands second must re-verify the other's ACs). - **#1929 — PDF layout robustness** (`bug`, **Must Have**). Three verified defects in the pdfmake pipeline: (a) `overviewPdf.ts` widths `['*','auto','auto','auto','auto','auto','*']` — five `auto` columns eat the printable width before the two `*` columns get anything, so the Usage column collapses and the table overflows the right page edge; the `allocatedAmount` cell is the worst offender because it carries an inline deposit badge + footnote markers. (b) `TABLE_LAYOUT` in `shared.ts` never sets `dontBreakRows`, so multi-line rows orphan across page breaks. (c) `buildPageHeader` renders ~60pt of content (14pt bold + 12pt subheader at `lineHeight: 1.4`, plus a 20pt bottom margin) into a **40pt** `pageMargins` top band → clipped and overlapping on pages 2+. ACs are outcome-focused; fixes are not prescribed. -- **#1930 — Attachment tier rules per report type** (`user-story`, Should Have). Replaces the per-invoice stage matching in `sourceReportService.ts` step h (~L286–339). **Tier order quotation(1) → deposit(2) → invoice(3); floors: budget-overview=1, claim=2, proof-of-funds=3; embed at-or-above the floor.** Depends only on report type + document type — no longer on invoice status, deposit split, or `targetStatuses`. **PR #1942 APPROVED round 1 (2026-08-02)**, all 11 AC met, 80/80 green. **But see #1943** — the architect found a frontend route that reaches AC2's forbidden outcome without violating AC2: `handleUseCaseChange` (`ReportWizardPage.tsx` L198–224) never clears `report`/`reportStatus`/`sourceId`, and step 2's Next is gated on `disabled={!sourceId}` (L686), which survives. Switching **budget-overview → claim** and clicking through carries a report filtered at the *budget-overview* tier floor into a claim export → **quotations embedded in a claim PDF handed to a bank**. Pre-existing (staled invoice slice + totals all along), but #1930 raised the consequence from a reconciliation error to an evidentiary one. Filed `bug` / **Must Have** / Todo, 2026-08-02; cross-referenced on #1930 (`issuecomment-5158312814`). **My ruling, recorded so it isn't re-litigated: clear `sourceId` too**, not just `report` — clearing `report` alone leaves the `!sourceId` gate satisfied, trading a stale-data bug for an empty-state bug. Clearing `sourceId` restores "step 3 is reachable only after an explicit source selection under the current use case", the same invariant step 1→2 already enforces; the extra click lands on a source list whose amounts were just re-fetched for the new use case. Watch the `?sourceId=` deep-link effect (L255–260, keyed on `!report`) — clearing `report` re-arms it, so #1943 AC8 requires that interaction be reasoned about explicitly. **Generalisable lesson: an AC that constrains a server-side derivation is not satisfied until the client is proven to re-derive it whenever its inputs change — check the state-reset paths, not just the computation.** +- **#1930 — Attachment tier rules per report type** (`user-story`, Should Have). Replaces the per-invoice stage matching in `sourceReportService.ts` step h (~L286–339). **Tier order quotation(1) → deposit(2) → invoice(3); floors: budget-overview=1, claim=2, proof-of-funds=3; embed at-or-above the floor.** Depends only on report type + document type — no longer on invoice status, deposit split, or `targetStatuses`. **PR #1942 APPROVED round 1 (2026-08-02)**, all 11 AC met, 80/80 green. **But see #1943** — the architect found a frontend route that reaches AC2's forbidden outcome without violating AC2: `handleUseCaseChange` (`ReportWizardPage.tsx` L198–224) never clears `report`/`reportStatus`/`sourceId`, and step 2's Next is gated on `disabled={!sourceId}` (L686), which survives. Switching **budget-overview → claim** and clicking through carries a report filtered at the _budget-overview_ tier floor into a claim export → **quotations embedded in a claim PDF handed to a bank**. Pre-existing (staled invoice slice + totals all along), but #1930 raised the consequence from a reconciliation error to an evidentiary one. Filed `bug` / **Must Have** / Todo, 2026-08-02; cross-referenced on #1930 (`issuecomment-5158312814`). **My ruling, recorded so it isn't re-litigated: clear `sourceId` too**, not just `report` — clearing `report` alone leaves the `!sourceId` gate satisfied, trading a stale-data bug for an empty-state bug. Clearing `sourceId` restores "step 3 is reachable only after an explicit source selection under the current use case", the same invariant step 1→2 already enforces; the extra click lands on a source list whose amounts were just re-fetched for the new use case. Watch the `?sourceId=` deep-link effect (L255–260, keyed on `!report`) — clearing `report` re-arms it, so #1943 AC8 requires that interaction be reasoned about explicitly. **Generalisable lesson: an AC that constrains a server-side derivation is not satisfied until the client is proven to re-derive it whenever its inputs change — check the state-reset paths, not just the computation.** - **#1931 — Single "Enhance with AI" action + purpose-focused prompt** (`user-story`, Should Have). Drops the step-4 "Enable AI assistance" toggle entirely (it gated nothing but a button), renders one button when `llmEnabled`, relabels "Generate with AI" → "Enhance with AI", and rewrites the prompt to explain **why** each cost was incurred rather than restating the table columns. - **#1932 — Cover letter overhaul** (`user-story`, Should Have). Formatted body (no markdown lib in `client/package.json` today — deliberately left as an architect/UX decision), explicit editable signature field + signature block, sender = user `displayName` + household address, professional letter layout, and the oversized reset-`X` fix. - **#1933 — Select Invoices step UI fixes** (`bug`, Should Have). Wrong glyph, no open-invoice affordance, misaligned select-all, misaligned deposit dates cell. ### Rulings made while writing these — do not re-litigate -- **`attachmentType: null` = tier `invoice`** (#1930). Rationale: nulls are legacy/ambiguous, not known-weak evidence — the invoice-creation Paperless picker hard-sets `'invoice'`, so nulls come from pre-#1877 links and from users skipping the type choice. Treating null as the *lowest* tier would silently drop evidence from claim/proof-of-funds reports for existing data, which is worse than being over-inclusive (the user can deselect). Treating it as tier 3 is exactly no-regression while still stopping typed quotations from reaching claim reports. **This supersedes #1888's deferred design question** — #1888 stays open but is re-scoped to indicator *presentation* only. +- **`attachmentType: null` = tier `invoice`** (#1930). Rationale: nulls are legacy/ambiguous, not known-weak evidence — the invoice-creation Paperless picker hard-sets `'invoice'`, so nulls come from pre-#1877 links and from users skipping the type choice. Treating null as the _lowest_ tier would silently drop evidence from claim/proof-of-funds reports for existing data, which is worse than being over-inclusive (the user can deselect). Treating it as tier 3 is exactly no-regression while still stopping typed quotations from reaching claim reports. **This supersedes #1888's deferred design question** — #1888 stays open but is re-scoped to indicator _presentation_ only. - **Server-side single filter** (#1930 AC7). `merge.ts` embeds whatever `invoice.documents` holds and `ReportInvoiceList` lights on `documents.length > 0`, so filtering once server-side makes step 3 and the PDF agree for free. Never add a second client-side document filter. ### #1930 shipped — PR #1942 APPROVED (2026-08-02, round 1) All 11 ACs met on head `4dfce4b8`; 80/80 tests green. Implementation is `server/src/services/shared/attachmentTierUtils.ts` (`ATTACHMENT_TIER`, `REPORT_TYPE_TIER_FLOOR`, `isDocumentIncludedForReportType`) — the single site for both the ordering and the floors. `splitByDepositsExcludingTagged` is gone from `sourceReportService`'s document path (still used by `budgetSourceService` for amounts — the #1930 Notes' "do not delete it" meant the util, not the local variable). Wiki `API-Contract.md` @ `a9b6e9e`. -- **QA deviation accepted**: AC1's table-driven scenario uses a *fresh invoice per report-type block* rather than one shared invoice queried three times. Correct call — no single invoice status sits in all three target slices (proof-of-funds needs `claimed`, which the claim slice excludes), so a shared fixture would have varied invoice-selection, the wrong variable. Status-invariance is proven separately by the `AC5` test. **General rule: when a table-driven test can't hold every variable constant, isolate the variable under test per block and prove the invariance claim in its own named test.** +- **QA deviation accepted**: AC1's table-driven scenario uses a _fresh invoice per report-type block_ rather than one shared invoice queried three times. Correct call — no single invoice status sits in all three target slices (proof-of-funds needs `claimed`, which the claim slice excludes), so a shared fixture would have varied invoice-selection, the wrong variable. Status-invariance is proven separately by the `AC5` test. **General rule: when a table-driven test can't hold every variable constant, isolate the variable under test per block and prove the invariance claim in its own named test.** - **Non-change-detecting tests are acceptable when the contract is asserted correctly** (informational finding I1). The proof-of-funds blocks of `scenario 16` and the `AC3` test would also have passed on `beta` (old stage derivation for a `claimed` no-deposit invoice also produced `stages={invoice}`). Flagged, not blocked — the ACs describe outcomes, and change-detection lives in the unit test plus AC1's budget-overview/claim blocks. - **#1888 re-scope APPLIED** (issue body rewritten 2026-08-02, was still stale at review time). Null-handling AC replaced by a pointer to the tier ruling; the "attached but not stage-matched" third state struck (non-qualifying docs never reach the client now); a "no client-side filtering" AC added to protect #1930 AC7; coordination note with #1933 (same glyph) added. **Lesson: a supersedes-ruling written into issue A does not update issue B — apply the re-scope to B's body at the same time, or it will be found stale at review.** - **#1909's "signature derived from sender" acceptance is REVERSED** (#1932). It was accepted at review time on the reasoning that `sender.split('\n')[0]` (the household name) was an adequate signatory; the user saw the output and rejected it. Record reversals like this rather than re-arguing them. - **#1925 closes as a duplicate of #1932** when #1932 lands; its ACs are carried forward verbatim as #1932 section 6. #1925's own Notes already anticipated this. - **The `Konstruktionsprojekt` prompt nit moves from #1917 to #1931.** `buildReportContentUserPrompt` L153 inverts the language ternary and is wrong in both branches; #1931 rewrites that prompt wholesale. #1917 keeps everything else, incl. the M2 `computeIncludedTotal` extraction and the `KI` glossary entry. -- **Prompt/validator cap divergence** (#1931). Prompt states 150/2000/200 (subject/body/description); `openAICompatibleProvider.ts` truncates at 200/3000/300. Resolution: one shared definition, effective values **150/2000/200** — the tighter set, partly because long descriptions aggravate #1929's Usage-column overflow. But #1929's ACs must hold at *any* length, since the step-5 editor is unbounded; neither issue may lean on the other. **Reaffirmed unchanged 2026-08-02** when ruling on #1929's AC conflict — a UI `maxLength` was explicitly rejected as the safety mechanism (see next entry). +- **Prompt/validator cap divergence** (#1931). Prompt states 150/2000/200 (subject/body/description); `openAICompatibleProvider.ts` truncates at 200/3000/300. Resolution: one shared definition, effective values **150/2000/200** — the tighter set, partly because long descriptions aggravate #1929's Usage-column overflow. But #1929's ACs must hold at _any_ length, since the step-5 editor is unbounded; neither issue may lean on the other. **Reaffirmed unchanged 2026-08-02** when ruling on #1929's AC conflict — a UI `maxLength` was explicitly rejected as the safety mechanism (see next entry). ### #1929 AC2-vs-AC4 conflict — ruling of 2026-08-02 (issue comment `5156932089`) PR #1935 got CHANGES_REQUIRED from both `product-architect` and `ux-designer`. Architect measured real pdfmake 0.3.11 renders: `dontBreakRows` on an unbreakable row **taller than one page** makes pdfmake **silently drop the row's content** (cliff ≈ 475 chars in the 7-col shape, flat at 14 text-show ops from 500 chars to 3000). So AC2 ("no characters dropped") and AC4 ("no row split across pages") were mutually exclusive at unbounded length. Ruling, now in the issue body: -- **Precedence ladder replaces flat peer ACs.** I1 no character lost > I2 nothing outside the printable area > I3 row stays on one page > I4 no word broken. Lower number wins on conflict. **I3 yields to I1** (option (a)): a row that *can* fit one page is never split; a row that genuinely cannot may span pages, but content is never silently dropped. Rationale: a split row is visible and recoverable (repeating header + reconcilable totals); a dropped row is undetectable in a document handed to a bank. Integrity > presentation. -- **AC2's "no word cut mid-word" was over-broad and got rewritten.** It described the *clipping* defect, not typographic line-breaking. As written it created a second latent contradiction: a pdfmake `'*'` column never renders below its widest word's width (`columnCalculator.js:66-75`), and `Wärmedämmverbundsystem` = 128pt at 10pt Roboto, so AC1 was unsatisfiable for unbounded German compounds. **Now: a word may be broken across lines iff it is wider than its column alone, losing no character.** This is what made the contract satisfiable — the general lesson is to check whether an AC forbids a legitimate mechanism while trying to forbid a defect. +- **Precedence ladder replaces flat peer ACs.** I1 no character lost > I2 nothing outside the printable area > I3 row stays on one page > I4 no word broken. Lower number wins on conflict. **I3 yields to I1** (option (a)): a row that _can_ fit one page is never split; a row that genuinely cannot may span pages, but content is never silently dropped. Rationale: a split row is visible and recoverable (repeating header + reconcilable totals); a dropped row is undetectable in a document handed to a bank. Integrity > presentation. +- **AC2's "no word cut mid-word" was over-broad and got rewritten.** It described the _clipping_ defect, not typographic line-breaking. As written it created a second latent contradiction: a pdfmake `'*'` column never renders below its widest word's width (`columnCalculator.js:66-75`), and `Wärmedämmverbundsystem` = 128pt at 10pt Roboto, so AC1 was unsatisfiable for unbounded German compounds. **Now: a word may be broken across lines iff it is wider than its column alone, losing no character.** This is what made the contract satisfiable — the general lesson is to check whether an AC forbids a legitimate mechanism while trying to forbid a defect. - **Rejected (b) truncation** (data loss on a bank document, politely announced) and **(c) a step-5 `maxLength`** — a UI cap is a UX affordance, not a correctness guarantee: it doesn't close the hole (vendor name + area line + attachments can still overflow a row), it isn't the only ingress (#1901 AI generation), and it puts a renderer invariant two layers away. A soft counter/hint is fine as separate future work — deliberately **not** filed. - **Targets set** (7-col shape, worst-case other columns, measured not estimated): **600 chars** of German prose with zero degradation (3× #1931's 200 target, 2× the 300 validator cap); Usage column fits **~30 chars of German prose per line** (69.28pt / ~14 chars is a collapsed column in product terms); table body font floor **8pt**. -- **Permitted levers widened, scope unchanged** (still presentation-layer only): padding, border widths, table body font down to 8pt, column widths, fixed-vs-star, page margins, and the row's internal layout — including taking the usage stack out of the 7-column grid into a full-width sub-row. The column grid is *not* fixed by the issue. 7 real columns + a prose column on A4 portrait is genuinely tight; say so explicitly or the implementer assumes the grid is a constraint. +- **Permitted levers widened, scope unchanged** (still presentation-layer only): padding, border widths, table body font down to 8pt, column widths, fixed-vs-star, page margins, and the row's internal layout — including taking the usage stack out of the 7-column grid into a full-width sub-row. The column grid is _not_ fixed by the issue. 7 real columns + a prose column on A4 portrait is genuinely tight; say so explicitly or the implementer assumes the grid is a constraint. - **No continuation marker** on split rows — needs page-aware rendering, too much risk on a blocking Must Have, case is rare once columns are right. -- **New ACs**: AC12 (measure the ceiling from real renders, record it in the issue *and* a code comment, pin with boundary tests both sides), AC13 (running header survives an unbounded `sourceName` — architect's MEDIUM 5), AC14 (falsy-`statusText` malformed-row crash at `overviewPdf.ts` L~160, verified: 6 cells pushed against a 7-entry `widths`). AC11 strengthened: config-only assertions don't satisfy it; AC1–AC4 each need a real-render assertion. **Fix order is part of the contract**: geometry first, *then* the unbreakable-rows flag, then the residual over-tall row — reversing it converts a visible defect into silent data loss. +- **New ACs**: AC12 (measure the ceiling from real renders, record it in the issue _and_ a code comment, pin with boundary tests both sides), AC13 (running header survives an unbounded `sourceName` — architect's MEDIUM 5), AC14 (falsy-`statusText` malformed-row crash at `overviewPdf.ts` L~160, verified: 6 cells pushed against a 7-entry `widths`). AC11 strengthened: config-only assertions don't satisfy it; AC1–AC4 each need a real-render assertion. **Fix order is part of the contract**: geometry first, _then_ the unbreakable-rows flag, then the residual over-tall row — reversing it converts a visible defect into silent data loss. - **Process lesson**: both this round's CRITICAL findings and my own AC conflict came from configuration asserted in a comment rather than measured against a real render (`dontBreakRows` on `layout` where pdfmake never reads it; a Usage width documented as 185.28pt that renders at 69.28pt because pdfmake subtracts ~116pt of cell offsets first). For any PDF/layout AC, require the assertion to be made against the rendered result. ## #1929 closed — PR #1935 merged 2026-08-02 (squash `1c5aa62c`), 4 rounds, 5 follow-ups filed Merged after four implementation rounds. Both `product-architect` and `ux-designer` reviewed by **rendering and rasterizing real PDFs** (throwaway Jest test → `/tmp` blob → `pdftoppm -r 150/300` → inspect PNGs), not by reading config — that technique is what caught every round's defect and is now the standard for any PDF-layout review here. -**The four-round arc, as a generalisable lesson** (architect, round 3): *"every cell that can hold unbounded text needs the cap, not just the first one that was noticed. Round 1 capped nothing, round 2 capped the wrong quantity (average glyph + perfect packing), round 3 capped the right quantity in the wrong scope (one field of a multi-field cell)."* Round 4 finally capped the right quantity at cell scope. When an AC is about a bound, ask **what quantity, at what scope** before accepting the fix. +**The four-round arc, as a generalisable lesson** (architect, round 3): _"every cell that can hold unbounded text needs the cap, not just the first one that was noticed. Round 1 capped nothing, round 2 capped the wrong quantity (average glyph + perfect packing), round 3 capped the right quantity in the wrong scope (one field of a multi-field cell)."_ Round 4 finally capped the right quantity at cell scope. When an AC is about a bound, ask **what quantity, at what scope** before accepting the fix. Final state worth knowing: table width is now **exactly 515.28pt, unfalsifiable by input** (no `'*'` column left; 22 pathological cases all identical to the hundredth). `MAX_SAFE_USAGE_CHUNK_CHARS = 650`, `MAX_SAFE_SMALL_CHUNK_CHARS = 450`, `PAGE_TOP_MARGIN = 75`, table body font 8pt, `VENDOR_WIDTH = 45pt`. AC12's 600-char zero-degradation guarantee holds. ### Follow-ups filed 2026-08-02 (all parentless, Bank Report Wizard cluster) -- **#1937 — German header labels break mid-word** (`bug`, Should Have, **Todo**). `Auftragnehmer` 67.50pt in a 45pt column, `Rechnungsbetrag` 78.66pt in 48pt. pdfmake 0.3.11 has no hyphenation mode; widening was **measured and rejected** (drops Usage to ~79pt, fails AC3's ~30-chars-per-line floor). Fix is at the **i18n layer** — 2 finite translator-owned strings in 1 locale, not an engineering fix. Ranked near-term because it shows on *every page of every German report*, unconditionally. +- **#1937 — German header labels break mid-word** (`bug`, Should Have, **Todo**). `Auftragnehmer` 67.50pt in a 45pt column, `Rechnungsbetrag` 78.66pt in 48pt. pdfmake 0.3.11 has no hyphenation mode; widening was **measured and rejected** (drops Usage to ~79pt, fails AC3's ~30-chars-per-line floor). Fix is at the **i18n layer** — 2 finite translator-owned strings in 1 locale, not an engineering fix. Ranked near-term because it shows on _every page of every German report_, unconditionally. - **#1938 — running header `generated at` label with no timestamp on pages 2+** (`bug`, Should Have, **Todo**). `merge.ts` L163–167 passes only `t('sourceReports.table.generatedAt')`; page 1 does it right at `overviewPdf.ts` L333 (`${label}: ${generatedAtText}`). **Pre-existing**, verified against `origin/beta` — not a #1929 regression. -- **#1939 — reportPdf geometry hygiene** (`tech-debt`, Should Have, **Todo**, **blocks #1932**). `HEADER_ROW_HEIGHT` → `HEADER_ROW_HEIGHT_MAX` (exports 68pt vs measured 45.81pt — correct *bound*, wrong *estimate*, and #1932 could under-fill whole pages reading it as typical); scope the `WORST_CASE_CHAR_ADVANCE_EM` comment (overclaimed at 0.89 and again at 1.04 — a 3,919-codepoint sweep found Cyrillic `Ѹ` U+0478 at 1.1611em; **value stays 1.04**, raising it drops the 7-col threshold 19→16 chars and breaks more German compounds); enumerate cell-content channels; relocate `PDF_STYLES` **down** into the geometry layer. -- **#1940 — continuation rows read as broken** (`enhancement`, Could Have, Backlog). The deferred "Could Have" from the #1929 ruling, now *observed*: `splitIntoPageSafeChunks` has no minimum trailing-chunk floor, so a row can carry a **single stray character** with all other columns blank. Only above the chunk ceilings, i.e. beyond AC12's guaranteed 600-char range; no data loss (I1 holds). +- **#1939 — reportPdf geometry hygiene** (`tech-debt`, Should Have, **Todo**, **blocks #1932**). `HEADER_ROW_HEIGHT` → `HEADER_ROW_HEIGHT_MAX` (exports 68pt vs measured 45.81pt — correct _bound_, wrong _estimate_, and #1932 could under-fill whole pages reading it as typical); scope the `WORST_CASE_CHAR_ADVANCE_EM` comment (overclaimed at 0.89 and again at 1.04 — a 3,919-codepoint sweep found Cyrillic `Ѹ` U+0478 at 1.1611em; **value stays 1.04**, raising it drops the 7-col threshold 19→16 chars and breaks more German compounds); enumerate cell-content channels; relocate `PDF_STYLES` **down** into the geometry layer. +- **#1940 — continuation rows read as broken** (`enhancement`, Could Have, Backlog). The deferred "Could Have" from the #1929 ruling, now _observed_: `splitIntoPageSafeChunks` has no minimum trailing-chunk floor, so a row can carry a **single stray character** with all other columns blank. Only above the chunk ceilings, i.e. beyond AC12's guaranteed 600-char range; no data loss (I1 holds). - **#1941 — editable override fields have no length limit** (`enhancement`, Could Have, Backlog). Zero `maxLength` in `client/src/components/reports/` or `EditableField/`; `attachmentsNote` is a client-side override that never round-trips, `areaText` is aggregate-unbounded (N × 200). **No longer a correctness risk** — round 4 bounded the renderer at cell scope. Input-side gap only. ### #1950 — guard test for the derived `Ѹ` ceiling (filed 2026-08-02 from PR #1948 round-3 review) -`tech-debt`, **Could Have**, Backlog, **blocked-by #1939**. Filed off the architect's PR #1948 approval comment ([5160266124](https://github.com/steilerDev/cornerstone/pull/1948#issuecomment-5160266124) §2), which **reframed its own earlier ask**: the deliverable is *not* re-running the 3,919-codepoint sweep, it's a **guard test that recomputes** the derived ceiling from `USAGE_WIDTH_7COL` / `TABLE_BODY_FONT_SIZE` / `TABLE_SMALL_FONT_SIZE` / `DEFAULT_LINE_HEIGHT`. Sweep left out as an explicit **non-goal**, not an optional AC — an "optional" AC isn't binary and makes the issue unfalsifiable. +`tech-debt`, **Could Have**, Backlog, **blocked-by #1939**. Filed off the architect's PR #1948 approval comment ([5160266124](https://github.com/steilerDev/cornerstone/pull/1948#issuecomment-5160266124) §2), which **reframed its own earlier ask**: the deliverable is _not_ re-running the 3,919-codepoint sweep, it's a **guard test that recomputes** the derived ceiling from `USAGE_WIDTH_7COL` / `TABLE_BODY_FONT_SIZE` / `TABLE_SMALL_FONT_SIZE` / `DEFAULT_LINE_HEIGHT`. Sweep left out as an explicit **non-goal**, not an optional AC — an "optional" AC isn't binary and makes the issue unfalsifiable. -The risk being guarded: `MAX_SAFE_USAGE_CHUNK_CHARS` (650) is **34 chars / 3 lines / 33.6pt over** its *derived* `Ѹ` ceiling of 616 (`44 lines × 14 chars`). Accepted on **input reachability** (needs 650 unbroken chars of archaic Church Slavonic Uk in one Usage cell), and because a `Ѹ`-safe value must sit in `[600, 616]`, collapsing AC12's margin over its 600-char floor from 8.3% to ~2.7%. `MAX_SAFE_SMALL_CHUNK_CHARS` (450) is genuinely safe (11.2% under 507). **Not a request to change 650** — the architect is comfortable with the risk. +The risk being guarded: `MAX_SAFE_USAGE_CHUNK_CHARS` (650) is **34 chars / 3 lines / 33.6pt over** its _derived_ `Ѹ` ceiling of 616 (`44 lines × 14 chars`). Accepted on **input reachability** (needs 650 unbroken chars of archaic Church Slavonic Uk in one Usage cell), and because a `Ѹ`-safe value must sit in `[600, 616]`, collapsing AC12's margin over its 600-char floor from 8.3% to ~2.7%. `MAX_SAFE_SMALL_CHUNK_CHARS` (450) is genuinely safe (11.2% under 507). **Not a request to change 650** — the architect is comfortable with the risk. Three durable rulings, all written into the issue rather than left implicit: -- **Comment and issue both, never one instead of the other.** The rationale stays in the code comment (AC 2.1 forbids moving/shortening/replacing it; AC 2.3 pins 650/450/1.04 and every width byte-identical) because *"anyone changing 650 or a column width reads that comment, not an issue tracker. Moving it out recreates the provenance loss that produced #1939."* The issue owns the **guard**; the comment owns the **rationale**. -- **Bounded-quantified vs unbounded-estimated is the line for "does this deserve a tracked owner."** `markerText` is unbounded with an estimated break-even → documentation only (folded into #1939). This is a bounded constant *provably* 34 chars past a derived ceiling → *"a quantified exceedance is a standing accepted risk with a number on it."* I would have collapsed these two; don't. -- **A derived bound with no test is a comment waiting to go stale.** Verified live: `overviewPdf.test.ts` pins `MEASURED_TRUE_CEILING` as re-typed `704`/`546` literals referencing **no geometry constant**, so widening the Usage column leaves them green while the real ceiling moves. Generalise: when a review accepts a *derived* number, ask what fails if its inputs change. +- **Comment and issue both, never one instead of the other.** The rationale stays in the code comment (AC 2.1 forbids moving/shortening/replacing it; AC 2.3 pins 650/450/1.04 and every width byte-identical) because _"anyone changing 650 or a column width reads that comment, not an issue tracker. Moving it out recreates the provenance loss that produced #1939."_ The issue owns the **guard**; the comment owns the **rationale**. +- **Bounded-quantified vs unbounded-estimated is the line for "does this deserve a tracked owner."** `markerText` is unbounded with an estimated break-even → documentation only (folded into #1939). This is a bounded constant _provably_ 34 chars past a derived ceiling → _"a quantified exceedance is a standing accepted risk with a number on it."_ I would have collapsed these two; don't. +- **A derived bound with no test is a comment waiting to go stale.** Verified live: `overviewPdf.test.ts` pins `MEASURED_TRUE_CEILING` as re-typed `704`/`546` literals referencing **no geometry constant**, so widening the Usage column leaves them green while the real ceiling moves. Generalise: when a review accepts a _derived_ number, ask what fails if its inputs change. AC 1.3 fails in **both** directions (growth widens a reviewed risk; shrinkage makes the comment's figure wrong). AC 1.6 keeps the measured 44/39-line budgets as the sole pinned literals, labelled as real-render measurements. The architect's two "informational, do not re-round" cosmetics (`~2.6%`→`~2.7%`, the self-asserted-infallibility sentence) were **already fixed at head `a6871975`** — checked before deciding, nothing folded in. ### Merge/scope decisions in this triage - **`markerText` (unbounded, ~250-skipped-doc break-even) and `invoiceNumber` (unbroken, capped at 100) were folded into #1939 as a documentation-only AC**, not filed separately. Their value is entirely "the next person reading this file knows the enumeration"— the same category as the comment-scoping work, and a standalone Could Have would never be picked up. AC7 + a scope guard forbid actually implementing a bound for them. -- **Vendor *data* breaking mid-word was recorded as an accepted limitation in #1937's Notes, not filed.** `ux-designer` round 4: at 45pt/8pt any 14+ char word breaks, and German trade names compound freely (`Rückerstattung` → `Rück`/`erstattung`) — a non-trivial minority of realistic names. Not filed because it is unbounded user data, AC2 permits it, nothing is lost, and the only lever (widening Vendor) costs Usage width and breaks AC3. Revisiting it needs a layout change, not a width tweak. -- **`PDF_STYLES` relocation had been deferred *to* #1932 in the round-3 review but never entered #1932's ACs** — it now lives in #1939 §4 so it isn't lost. Watch for this pattern: "we'll handle it in issue X" is only real if it lands in X's acceptance criteria. +- **Vendor _data_ breaking mid-word was recorded as an accepted limitation in #1937's Notes, not filed.** `ux-designer` round 4: at 45pt/8pt any 14+ char word breaks, and German trade names compound freely (`Rückerstattung` → `Rück`/`erstattung`) — a non-trivial minority of realistic names. Not filed because it is unbounded user data, AC2 permits it, nothing is lost, and the only lever (widening Vendor) costs Usage width and breaks AC3. Revisiting it needs a layout change, not a width tweak. +- **`PDF_STYLES` relocation had been deferred _to_ #1932 in the round-3 review but never entered #1932's ACs** — it now lives in #1939 §4 so it isn't lost. Watch for this pattern: "we'll handle it in issue X" is only real if it lands in X's acceptance criteria. - **Not filed:** the page-1 `PAGE_TOP_MARGIN = 93pt` blank gap above the cover-letter sender block — already inside #1932 AC 4.1; flagged on #1932 rather than duplicated. - `addBlockedBy(#1932 ← #1939)` set, plus a prominent sequencing comment on #1932 (`issuecomment-5158212341`) covering the block, the `PDF_STYLES` direction constraint (`pageGeometry.ts` must **never** import `merge.ts` — that edge already runs the other way), and the #1941/#1938 shared-ground warnings. @@ -244,9 +244,9 @@ AC 3.2/3.3 assert **live model output quality** ("reads as a purpose statement", ### Other rulings -- **"Mit KI verbessern" accepted for AC 2.3.** My AC deliberately did not prescribe the string ("an equivalent in German that uses 'KI', consistent with existing `de` copy") — wording is `ux-designer`/`translator` territory. *verbessern* (improve existing) over *überarbeiten* (rework) is right and matches the English: the whole point of renaming Generate→Enhance was that the action improves content that already exists; *überarbeiten* would reintroduce in German the overstatement removed in English. +- **"Mit KI verbessern" accepted for AC 2.3.** My AC deliberately did not prescribe the string ("an equivalent in German that uses 'KI', consistent with existing `de` copy") — wording is `ux-designer`/`translator` territory. _verbessern_ (improve existing) over _überarbeiten_ (rework) is right and matches the English: the whole point of renaming Generate→Enhance was that the action improves content that already exists; _überarbeiten_ would reintroduce in German the overstatement removed in English. - **Unconditional `aria-describedby` description accepted as in-scope** though not literally in an AC: deleting the checkbox deleted its helper text, which was the only place overwrite behaviour was explained. Dirty-gating it would hide the warning from the user who most needs it. -- **Good AC-writing pattern to repeat**: AC 4.1 asked for "exactly one definition that both sides derive from". `contentLimits.test.ts` satisfied it by building its expected substrings *by interpolating the constant*, never typing the literal — so a hardcoded number reappearing in `prompts.ts` fails the assertion instead of silently passing. Ask for derivation, not equality. +- **Good AC-writing pattern to repeat**: AC 4.1 asked for "exactly one definition that both sides derive from". `contentLimits.test.ts` satisfied it by building its expected substrings _by interpolating the constant_, never typing the literal — so a hardcoded number reappearing in `prompts.ts` fails the assertion instead of silently passing. Ask for derivation, not equality. - Non-blocking follow-ups left on the PR (not filed): user-prompt tail still says `letterBody` "summarizing the report" (old framing, weaker instruction sitting closer to the output — first suspect if UAT 3.4 fails); stale E2E locator name `generateWithAiButton` vs the "Enhance with AI" accessible name. ### #1917 bookkeeping done @@ -259,29 +259,29 @@ AC 3.2/3.3 assert **live model output quality** ("reads as a purpose statement", ### The M1 precedent: a finding that defeats the PR's own AC is not a follow-up -M1 was an untokenized `getSourceReport` race — an in-flight fetch from the *previous* use case could win an out-of-order resolution and re-populate `report` while step 3 was reachable, reaching **exactly the #1943 end state** (claim export embedding quotation-tier docs). Filing it would have marked #1943's AC1 met while a live route to the headline defect remained open. Fixed in-PR with a monotonic `reportRequestRef` token (`ReportWizardPage.tsx` L146/229/269/274/281). **Rule: a review finding that defeats an AC of the story under review belongs in that story's PR, regardless of the reviewer's medium/low severity label** — reviewer severity answers "does this block merge", not "is the AC actually met". +M1 was an untokenized `getSourceReport` race — an in-flight fetch from the _previous_ use case could win an out-of-order resolution and re-populate `report` while step 3 was reachable, reaching **exactly the #1943 end state** (claim export embedding quotation-tier docs). Filing it would have marked #1943's AC1 met while a live route to the headline defect remained open. Fixed in-PR with a monotonic `reportRequestRef` token (`ReportWizardPage.tsx` L146/229/269/274/281). **Rule: a review finding that defeats an AC of the story under review belongs in that story's PR, regardless of the reviewer's medium/low severity label** — reviewer severity answers "does this block merge", not "is the AC actually met". ### #1946 (bug, **Must Have**, Todo) — in-flight AI generation survives a use-case change -`runAiGeneration` resolves into `setAiContent(result)`; while generating, `aiContent` is `null`, so `guardedUpdate`'s dirty predicate is false and a use-case change applies **with no confirmation**. Post-#1931 the prompt is purpose-focused and the request carries `type: useCase`, so the landed result is narrative written for the **wrong report purpose** — the architect's point that the *mechanism* is symmetric with a source change but the *consequence* is not. +`runAiGeneration` resolves into `setAiContent(result)`; while generating, `aiContent` is `null`, so `guardedUpdate`'s dirty predicate is false and a use-case change applies **with no confirmation**. Post-#1931 the prompt is purpose-focused and the request carries `type: useCase`, so the landed result is narrative written for the **wrong report purpose** — the architect's point that the _mechanism_ is symmetric with a source change but the _consequence_ is not. - **Must Have despite the architect's "Medium"** — recorded on the issue to stop re-litigation. Medium-for-PR and MoSCoW are different scales; the architect explicitly asked for it before the cluster's `beta`→`main` promotion. Same blast radius as #1943/#1929: credibility of a bank-facing artifact. -- **Product ruling on the discard question (option b of three)**: widen `guardedUpdate`'s dirty predicate to include `isGeneratingAi` → the confirmation runs; confirm invalidates the in-flight request via a token, cancel lets it finish. Rejected (a) silent invalidation — generation is slow (visible elapsed timer) and metered, the guard exists to protect content that isn't cheap to recreate, and an in-flight generation *is* that, just not arrived. Rejected (c) block-until-settled — freezes the wizard for up to `LLM_REQUEST_TIMEOUT_MS` and invents a trapped-behind-a-hung-request failure mode. **Never trap a user behind a network call they can't cancel.** +- **Product ruling on the discard question (option b of three)**: widen `guardedUpdate`'s dirty predicate to include `isGeneratingAi` → the confirmation runs; confirm invalidates the in-flight request via a token, cancel lets it finish. Rejected (a) silent invalidation — generation is slow (visible elapsed timer) and metered, the guard exists to protect content that isn't cheap to recreate, and an in-flight generation _is_ that, just not arrived. Rejected (c) block-until-settled — freezes the wizard for up to `LLM_REQUEST_TIMEOUT_MS` and invents a trapped-behind-a-hung-request failure mode. **Never trap a user behind a network call they can't cancel.** - **Widen the predicate inside `guardedUpdate` itself, not per-handler** — every caller mutates an AI-request input or the baseline; one place makes the invariant structural, which is the whole lesson of #1943. Closes the symmetric source-change variant for free. - AC12 explicitly **forbids new E2E**: a late-resolving stale response is invisible to the assertions (architect's own analysis of scenarios 13/14). Deterministic unit test with controlled promise resolution, not a timing spec. ### #1947 (tech-debt, **Should Have**, Backlog, blocked-by #1946) — `useReducer` refactor -1,156 lines / 38 hooks; the "what a transition invalidates" invariant is hand-maintained across two handlers. **Filed as its own issue, NOT folded into #1912** — #1912 is a Could Have grab-bag of cosmetic nits; folding a state-machine refactor in would bury it behind a Could Have label, make #1912 un-sizable, and lose the evidence trail, which *is* the justification. +1,156 lines / 38 hooks; the "what a transition invalidates" invariant is hand-maintained across two handlers. **Filed as its own issue, NOT folded into #1912** — #1912 is a Could Have grab-bag of cosmetic nits; folding a state-machine refactor in would bury it behind a Could Have label, make #1912 un-sizable, and lose the evidence trail, which _is_ the justification. -- **The evidence table is the argument**: one handler produced four defects of one shape in one batch — #1943 (transition didn't invalidate state it owned), its AC8 deep-link second-order effect (the fix created the next bug), M1 (pending write re-populated cleared state), M2/#1946 (same, plus the guard couldn't see what it guards). Two of the four were *caused by the patch before them*. Put that table in any future "should we refactor" argument. -- **Should Have, with a checkable trigger instead of a vague "soon"**: *the next change that adds transition-owned state to this component should be preceded by this refactor.* Raise at refinement if a report-wizard state story lands while it's open. +- **The evidence table is the argument**: one handler produced four defects of one shape in one batch — #1943 (transition didn't invalidate state it owned), its AC8 deep-link second-order effect (the fix created the next bug), M1 (pending write re-populated cleared state), M2/#1946 (same, plus the guard couldn't see what it guards). Two of the four were _caused by the patch before them_. Put that table in any future "should we refactor" argument. +- **Should Have, with a checkable trigger instead of a vague "soon"**: _the next change that adds transition-owned state to this component should be preceded by this refactor._ Raise at refinement if a report-wizard state story lands while it's open. - Architect's **L3** (`deepLinkAppliedRef` boolean → `useRef`) folded in as a nice-to-have per coordinator — the applied id is immutable for the component's lifetime (sole `?sourceId=` producer is a cross-route `navigate()` from `BudgetSourcesPage.tsx:1318`), so a boolean is sufficient today. ### #1943 body amended + audit comment (`issuecomment-5159716825`) -- **AC4 reworded — original was unsatisfiable by design.** "always identical to a clean start" is violated by `attachDocuments` and `reportLanguageOverride`, which are *correctly* sticky. **A UAT tester reading it literally would have failed the story for working as designed.** Now scoped to the `getSourceReport` payload, the exclusion sets, and the tier floor, with the two preferences named as out-of-scope. **Pattern to watch: an equivalence AC must name what is excluded, or sticky user preferences will read as failures.** -- **AC5 enumeration completed — `skippedDocuments` and `aiError` were omitted, both ruled CLEAR** (not KEEP, against the architect's "probably fine"). `skippedDocuments` is only overwritten on a *successful* generation, so a later failure re-displays the previous report's warnings against a new report — in a bank-facing flow. `aiError` can hold `EMPTY_SELECTION`, raised from exclusion sets this very reset clears, so it's guaranteed inapplicable. Both one-line, zero reachability risk. +- **AC4 reworded — original was unsatisfiable by design.** "always identical to a clean start" is violated by `attachDocuments` and `reportLanguageOverride`, which are _correctly_ sticky. **A UAT tester reading it literally would have failed the story for working as designed.** Now scoped to the `getSourceReport` payload, the exclusion sets, and the tier floor, with the two preferences named as out-of-scope. **Pattern to watch: an equivalence AC must name what is excluded, or sticky user preferences will read as failures.** +- **AC5 enumeration completed — `skippedDocuments` and `aiError` were omitted, both ruled CLEAR** (not KEEP, against the architect's "probably fine"). `skippedDocuments` is only overwritten on a _successful_ generation, so a later failure re-displays the previous report's warnings against a new report — in a bank-facing flow. `aiError` can hold `EMPTY_SELECTION`, raised from exclusion sets this very reset clears, so it's guaranteed inapplicable. Both one-line, zero reachability risk. - **Carried as #1946 AC9/AC10, not by reopening #1943** — the PR is approved and neither is a defect in what it shipped; the gap was in the enumeration, which now lives on the issue. Said so explicitly on the comment so the addendum doesn't read as goalposts moving after approval. ## #1933 ACs 2.1/2.7 corrected 2026-08-02 — AC described a layout that doesn't exist @@ -296,7 +296,7 @@ Reworded to "every viewport" with the 44×44px target **unconditional** (strictl - Before writing a viewport- or layout-conditional AC, **check the CSS for an actual breakpoint** — don't infer a responsive variant from the presence of `mobileCard` classes elsewhere in the same file. - For equivalence/"identical to" ACs, **name what is excluded** or sticky user preferences will read as failures. -- When an AC is corrected on an open issue, annotate the AC inline with a date + pointer to the correction comment, and say in the comment *why*, so the original premise isn't reintroduced from memory of the old text. +- When an AC is corrected on an open issue, annotate the AC inline with a date + pointer to the correction comment, and say in the comment _why_, so the original premise isn't reintroduced from memory of the old text. ## #1932 user scope ruling 2026-08-02 — plain text with line breaks, not markdown @@ -306,13 +306,13 @@ Comments: `issuecomment-5160251632` (decision), `issuecomment-5160258752` (AC ch ### Premise correction — the stated defect was largely false -**pdfmake already honours `\n`.** `node_modules/pdfmake/js/TextBreaker.js` L30–34 and L53–58 treat `\n`/`\r\n` as a *required* line end, so a single text node renders embedded newlines as line breaks. The sender block has depended on this all along (`senderLines.join('\n')` in one node, pinned in `coverLetterPdf.test.ts` L80–90). The body's line-break round trip **already works and is merely unpinned**. +**pdfmake already honours `\n`.** `node_modules/pdfmake/js/TextBreaker.js` L30–34 and L53–58 treat `\n`/`\r\n` as a _required_ line end, so a single text node renders embedded newlines as line breaks. The sender block has depended on this all along (`senderLines.join('\n')` in one node, pinned in `coverLetterPdf.test.ts` L80–90). The body's line-break round trip **already works and is merely unpinned**. Consequence: #1932 section 1 collapsed from a feature build to **regression guards + one new requirement**. Worth keeping the guard anyway — the per-token inline-run technique used elsewhere in `reportPdf/` for pdfmake's all-or-nothing `wordBreak` would silently destroy `\n` handling if ever applied to the body. That is a working-but-unpinned behaviour with a plausible silent breaker, which is exactly what a test is for. **Lesson: verify a "does not survive rendering" claim against the renderer's source before writing ACs around fixing it.** Cheap (one grep in `node_modules`), and it flipped this section's size. -### The plain-text ruling *created* one requirement rather than removing it +### The plain-text ruling _created_ one requirement rather than removing it **AC 1.6 is now load-bearing.** `server/src/services/budgetExtraction/prompts.ts` L~142 ("Letter body") says nothing about output format. An LLM asked for a business letter readily emits `**emphasis**` and `- bullets`; under plain-text rendering those print as literal asterisks in the PDF a bank reads. Same defect class as the #1916 prompt-input findings — **when a formatting model is simplified, re-check what the LLM prompt assumes about it.** @@ -329,7 +329,7 @@ Deleting a vacuous AC reads as an oversight and invites re-litigation; striking Sections 2 (signature), 3 (sender), 4 (layout), 5 (reset-X CSS) do **not** depend on the formatting model. Two caveats found on disk: - **§2 ↔ §3 are coupled to each other**, in code today: `applyOverrides.ts` L66–68 recomputes `signature` from an overridden sender (`sender.split('\n')[0]`), `types.ts` L44 documents `signature` as `DERIVED`, and `realRender.test.ts` L997 pins the recompute. Making signature first-class means a sender edit must stop overwriting an explicit signature, and that test must be **updated, not deleted**. Filed as new **AC 2.6** — would otherwise have been a review-time surprise. -- **Paragraph *spacing* moved §1 → §4.** With no markup carrying paragraph semantics, whether a blank line stays a full empty line or becomes typographic spacing is a layout call. AC 4.1 amended to own it. +- **Paragraph _spacing_ moved §1 → §4.** With no markup carrying paragraph semantics, whether a blank line stays a full empty line or becomes typographic spacing is a layout call. AC 4.1 amended to own it. - §5 (reset-X) is pure shared-component CSS and is fully severable — could be split out if #1932 ever needs shrinking. ### #1925 fold-in unaffected @@ -350,15 +350,15 @@ All 40 ACs met on `c17d9d44` + the locally-committed E2E follow-up `d60a98b3`. P ### Rulings worth reusing -- **AC 1.2 — a line-count-plus-spacing proof satisfies a "real render" AC.** `.positions.length` read off a node after `getBlob()`, plus uniform non-zero inter-line gaps, is *sufficient* proof that typed line/blank-line structure survived — no per-line text reconstruction needed — **when the body is a single text node whose `.text` is separately asserted byte-identical**. It genuinely discriminates: a collapsed blank line gives 3 not 4, and a per-token inline-run reflow (the #1929 `wordBreak` technique) destroys `\n` and fails. `._inlines` is the wrong signal — LayoutBuilder drains it to `[]` via `.shift()`; `.positions` is what survives with the right cardinality. -- **"Updated, not deleted" is satisfied by "kept intact and still correct."** `realRender.test.ts:1057` (sender-override recomputes signature) was left untouched and still passes — it now describes the *fallback* branch. My AC's real concern was deletion of the pin. Downgraded the un-reworded title to informational; the adjacent AC 2.6 test is the actual guard against restoring the unconditional recompute. +- **AC 1.2 — a line-count-plus-spacing proof satisfies a "real render" AC.** `.positions.length` read off a node after `getBlob()`, plus uniform non-zero inter-line gaps, is _sufficient_ proof that typed line/blank-line structure survived — no per-line text reconstruction needed — **when the body is a single text node whose `.text` is separately asserted byte-identical**. It genuinely discriminates: a collapsed blank line gives 3 not 4, and a per-token inline-run reflow (the #1929 `wordBreak` technique) destroys `\n` and fails. `._inlines` is the wrong signal — LayoutBuilder drains it to `[]` via `.shift()`; `.positions` is what survives with the right cardinality. +- **"Updated, not deleted" is satisfied by "kept intact and still correct."** `realRender.test.ts:1057` (sender-override recomputes signature) was left untouched and still passes — it now describes the _fallback_ branch. My AC's real concern was deletion of the pin. Downgraded the un-reworded title to informational; the adjacent AC 2.6 test is the actual guard against restoring the unconditional recompute. - **Chrome-vs-content adjacency in different languages is correct, not broken.** `closingLabel` ("Grußformel", interface `t()`) sitting directly above `closing` ("Sincerely,", `reportT`) is #1909's rule applied consistently — same relationship "Betreff" already has with English subject text. Stacking them **vertically** is what makes it read as caption-and-artifact rather than one broken sentence. This is also the whole basis of Option B below. - **#1925 Option B (restyle caption as chrome) beat Option A (`reportT` the caption)** because Option A would have fixed AC 6.1 by breaking AC 6.2 — every sibling caption in that panel is interface-language, so translating only one makes it the single inconsistent caption. - **Duplicate closure transfers ownership; it does not require every AC independently green.** Closed #1925 (board Wont-Do) with one AC only partially met, moving that residual to a MUST FIX on the PR where it would actually be acted on. Keeping it open would track the same work twice. ### My own AC-transcription error — second instance of this failure mode -**#1925 has SIX ACs; my #1932 §6 carried four** and I wrote "all four carried ACs stand verbatim." Dropped its AC3 (PDF date stays bare/label-free) and AC5 (unit pins both sides). Both had to be checked at review time, and AC5 turned out **partial** — the PDF side is pinned by exact equality, the editor side pins only pre-existing behaviour, not the colon-free caption that *is* the fix. **Rule: when folding issue B into issue A, count B's ACs and map every one explicitly — a dropped AC surfaces as an unverified claim at close time.** Companion to the #1933 AC 2.1/2.7 entry (ACs that misdescribe reality); this is ACs that silently go missing. +**#1925 has SIX ACs; my #1932 §6 carried four** and I wrote "all four carried ACs stand verbatim." Dropped its AC3 (PDF date stays bare/label-free) and AC5 (unit pins both sides). Both had to be checked at review time, and AC5 turned out **partial** — the PDF side is pinned by exact equality, the editor side pins only pre-existing behaviour, not the colon-free caption that _is_ the fix. **Rule: when folding issue B into issue A, count B's ACs and map every one explicitly — a dropped AC surfaces as an unverified claim at close time.** Companion to the #1933 AC 2.1/2.7 entry (ACs that misdescribe reality); this is ACs that silently go missing. ### Findings filed as MUST FIX on #1951 (non-blocking) @@ -381,10 +381,10 @@ Architect's PR #1951 review ([comment 5160566459](https://github.com/steilerDev/ ### Rulings worth reusing -- **Coerce-vs-reject at an LLM response boundary: match the policy the field already has.** Ruled #1952 as *strip*, not *reject*. Decisive argument was **blast radius per call**: one generation produces subject + body + every per-invoice description, so rejecting over two asterisks discards unrelated correct output, costs a second paid round-trip, and may fail identically on retry with the same model. Reinforcing: the validator already *truncates* over-length values on these very fields, so adding a harsher policy for a *milder* violation is incoherent; and the fields are human-editable in the preview, which makes repair-and-continue the right default with the human as backstop. Generalizable: **at an LLM boundary, prefer the repair that preserves the expensive parts of the response, and never introduce a stricter failure mode for a cosmetic defect than the one already accepted for a structural one.** +- **Coerce-vs-reject at an LLM response boundary: match the policy the field already has.** Ruled #1952 as _strip_, not _reject_. Decisive argument was **blast radius per call**: one generation produces subject + body + every per-invoice description, so rejecting over two asterisks discards unrelated correct output, costs a second paid round-trip, and may fail identically on retry with the same model. Reinforcing: the validator already _truncates_ over-length values on these very fields, so adding a harsher policy for a _milder_ violation is incoherent; and the fields are human-editable in the preview, which makes repair-and-continue the right default with the human as backstop. Generalizable: **at an LLM boundary, prefer the repair that preserves the expensive parts of the response, and never introduce a stricter failure mode for a cosmetic defect than the one already accepted for a structural one.** - **When a hardening AC's real risk is false positives, weight the ACs there.** #1952's §2 (six byte-identical-passthrough guards: `Pos. 3 - Dachstuhl`, `Rechnung #2024-117`, `Beträge < 500 EUR`, lone `*`, umlauts/`ß`/`€`, and the compliant-body case) is as long as §1 (the stripping itself). Reason recorded in-issue: a strip that mangles a reference number is worse than the markup, because the reader cannot tell a character went missing. Also AC 1.8 — if stripping empties a non-empty field, keep the original. -- **"Don't repeat the literal" is not "these are the same value."** #1953's whole ruling. The UX spec directed reusing `SUBHEADER_FONT_SIZE` with the rationale *"don't hand-write `fontSize: 12` as a second copy of that constant"* — a magic-literal argument. Its design reasoning for the subject line ("bold + bumped size makes it read as a subject") is standalone and never references the running header. So the equality is **coincidental** and the fix is an **independent literal**; the architect's suggested `const LETTER_SUBJECT_FONT_SIZE = SUBHEADER_FONT_SIZE;` is explicitly ruled out because it fixes the name while preserving the coupling. **When a shared constant is challenged, read the sharing rationale for whether it argues DRY or argues semantic identity — only the latter justifies keeping the share.** -- **#1939's drift class has an inverse, and it needs filing too.** #1939 removed *two drifting copies of one value*; #1953 splits *one shared name over two values that happen to be equal*. Same symptom (an edit with a consequence the author never looked at), opposite cause. #1937 and #1938 are both open against that same running header, which is what makes the split worth doing *before* they land. +- **"Don't repeat the literal" is not "these are the same value."** #1953's whole ruling. The UX spec directed reusing `SUBHEADER_FONT_SIZE` with the rationale _"don't hand-write `fontSize: 12` as a second copy of that constant"_ — a magic-literal argument. Its design reasoning for the subject line ("bold + bumped size makes it read as a subject") is standalone and never references the running header. So the equality is **coincidental** and the fix is an **independent literal**; the architect's suggested `const LETTER_SUBJECT_FONT_SIZE = SUBHEADER_FONT_SIZE;` is explicitly ruled out because it fixes the name while preserving the coupling. **When a shared constant is challenged, read the sharing rationale for whether it argues DRY or argues semantic identity — only the latter justifies keeping the share.** +- **#1939's drift class has an inverse, and it needs filing too.** #1939 removed _two drifting copies of one value_; #1953 splits _one shared name over two values that happen to be equal_. Same symptom (an edit with a consequence the author never looked at), opposite cause. #1937 and #1938 are both open against that same running header, which is what makes the split worth doing _before_ they land. - **Record an architect's deferral trigger in the file, not only in the issue.** `letterSubject` is the first `PDF_STYLES` entry with no geometry consumer; architect set the split trigger at the **second** one, target shape `pageGeometry ← pdfStyles ← merge`. #1953 AC 3.1 puts that in the `pageGeometry.ts` module header. Same principle as #1950's "comment owns the rationale, issue owns the guard" — a trigger recorded only in a closed issue is lost. - **Amend an honest interim wiki statement, don't delete it.** #1932's PR adds "instructed but not enforced" to `API-Contract.md`; #1952 AC 4.1 says amend that bullet. Prevents the next author reading a stale "not enforced" line after enforcement lands. @@ -405,28 +405,28 @@ The "No new E2E coverage — deferred" line was already corrected (now lists Sce #1959 removed the `†`/`‡` markers **and their explanatory sentences**, replacing them with inline grey labels on the allocated amount: `(partial)`/`(Teilbetrag)` and `(less deposit)`/`(abzgl. Abschlag)`. This reverses **#1923 AC1.1, AC1.2, AC2.3, AC2.4** — which #1898 §4 had itself already been superseded by. `allocatedMarkers` gone from `types.ts`, replaced by `isSplit`/`isDepositReduced`. -**Ruling: labels accepted, sentences must come back as a report-level legend (#1965, Must Have).** The differentiator is *which* sentence is load-bearing: +**Ruling: labels accepted, sentences must come back as a report-level legend (#1965, Must Have).** The differentiator is _which_ sentence is load-bearing: - `(partial)` is nearly self-evident — the table prints **Invoice Amount** and **Allocated Amount** side by side, so the label only has to name the reason for a difference the reader can already see. - `(less deposit)` loses a **materially different claim**. The footnote said the deposit was claimed **separately** — accounted for in another submission. "less deposit" is equally consistent with the deposit never being claimed, and leaves a double-claim question open when it resurfaces. On a Verwendungsnachweis/Mittelabruf that has audit consequences. -**Generalizable: for outbound financial copy, ask what a label lets the reader *conclude*, not whether it is accurate. `(less deposit)` is true and still misleading by omission.** +**Generalizable: for outbound financial copy, ask what a label lets the reader _conclude_, not whether it is accurate. `(less deposit)` is true and still misleading by omission.** -**Why the fix is cheap (checked, load-bearing for the ruling):** the footnote *rendering channel is fully intact* and merely unused. `buildReportContent.ts` declares `footnotes: ReportContentFootnote[] = []` (L232) and returns it (L289) but never pushes; `ReportContentEditor.tsx` L445-450 renders it; `overviewPdf.ts` appends `reportContent.footnotes` **verbatim** (pinned by the `overviewPdf.test.ts` case "appends reportContent.footnotes verbatim…", which is also the only surviving reader of the two i18n keys — as fixture text). So the legend is a **producer-only change** and preview/PDF parity comes free. **Pattern: before ruling a restoration too expensive, check whether the mechanism was removed or only orphaned.** +**Why the fix is cheap (checked, load-bearing for the ruling):** the footnote _rendering channel is fully intact_ and merely unused. `buildReportContent.ts` declares `footnotes: ReportContentFootnote[] = []` (L232) and returns it (L289) but never pushes; `ReportContentEditor.tsx` L445-450 renders it; `overviewPdf.ts` appends `reportContent.footnotes` **verbatim** (pinned by the `overviewPdf.test.ts` case "appends reportContent.footnotes verbatim…", which is also the only surviving reader of the two i18n keys — as fixture text). So the legend is a **producer-only change** and preview/PDF parity comes free. **Pattern: before ruling a restoration too expensive, check whether the mechanism was removed or only orphaned.** **Legend must NOT go in the cover letter** — it is user-editable (#1932), so an editable qualification can be deleted, silently removing a material statement from a financial document. Report-level and non-editable, like the existing footnote block. -**The orphan-key cleanup was deliberately NOT filed as a deletion issue.** `splitFootnote`/`depositReducedFootnote` are *consumed* by #1965, so filing "delete these orphans" would race the legend. #1965 AC 3.2 pins retention; its Notes record deletion as the Wont-Do alternative. **Pattern: when a cleanup follow-up and a restoration follow-up target the same artifact, one issue must own both or the cleanup wins by arriving first.** +**The orphan-key cleanup was deliberately NOT filed as a deletion issue.** `splitFootnote`/`depositReducedFootnote` are _consumed_ by #1965, so filing "delete these orphans" would race the legend. #1965 AC 3.2 pins retention; its Notes record deletion as the Wont-Do alternative. **Pattern: when a cleanup follow-up and a restoration follow-up target the same artifact, one issue must own both or the cleanup wins by arriving first.** ### What was NOT an AC reversal (checked, initially framed as one) -**#1923 AC5.3 substance survives.** The area name moved from a sub-line to an inline grey suffix, but `areaText` is **still a separate row field** (`buildReportContent.ts` L196/L214) and `applyAiContent.ts` still only assigns `row.usageText` (L50) — so AI-generated usage text cannot drop the area, which was AC5.3's *stated rationale*. #1923's own Notes delegated the area sub-line's **visual treatment** to `ux-designer`. So E2E Scenario 20's rewrite is a presentation change within delegated authority. **Pattern: an AC that states its own rationale should be judged against the rationale, not the prescribed rendering — that is what the rationale is there for.** +**#1923 AC5.3 substance survives.** The area name moved from a sub-line to an inline grey suffix, but `areaText` is **still a separate row field** (`buildReportContent.ts` L196/L214) and `applyAiContent.ts` still only assigns `row.usageText` (L50) — so AI-generated usage text cannot drop the area, which was AC5.3's _stated rationale_. #1923's own Notes delegated the area sub-line's **visual treatment** to `ux-designer`. So E2E Scenario 20's rewrite is a presentation change within delegated authority. **Pattern: an AC that states its own rationale should be judged against the rationale, not the prescribed rendering — that is what the rationale is there for.** ### Stale ACs on closed/released issues — comment, don't rewrite -Both #1898 and #1923 are CLOSED + `released on @beta`. **Ruled: do not rewrite their ACs; post a dated supersession comment** naming which ACs died, by which PR, what still holds, and where the new source of truth is. Rewriting a released story's ACs falsifies the record of what was accepted and loses the design reasoning. The standing "stale AC is a real defect" rule targets ACs that a *future* implementer or UAT run will read as live spec — a supersession comment discharges that risk without destroying history. Posted on both (#1923 comment also lists the non-superseded ACs explicitly, since AC2.1/2.2/2.5/3/4 all still hold). +Both #1898 and #1923 are CLOSED + `released on @beta`. **Ruled: do not rewrite their ACs; post a dated supersession comment** naming which ACs died, by which PR, what still holds, and where the new source of truth is. Rewriting a released story's ACs falsifies the record of what was accepted and loses the design reasoning. The standing "stale AC is a real defect" rule targets ACs that a _future_ implementer or UAT run will read as live spec — a supersession comment discharges that risk without destroying history. Posted on both (#1923 comment also lists the non-superseded ACs explicitly, since AC2.1/2.2/2.5/3/4 all still hold). -Also noted on #1898: its §4 has now been superseded **twice** (#1898 → #1923 → #1959). That churn is itself the argument that the footnote *presentation* was never settled, and it supports abandoning the glyphs — which is why #1965 keeps the sentences but not the markers. +Also noted on #1898: its §4 has now been superseded **twice** (#1898 → #1923 → #1959). That churn is itself the argument that the footnote _presentation_ was never settled, and it supports abandoning the glyphs — which is why #1965 keeps the sentences but not the markers. ### Glossary: `Abschlag` APPROVED as a space-constrained short form @@ -434,61 +434,205 @@ Full ruling on **#1917** (the pending glossary-refinement pass, same place the ` `depositReducedInlineLabel` = `abzgl. Abschlag` ships as written, against the glossary-approved `Deposit → Abschlagszahlung`. Translator measured with fontkit against the real embedded `Roboto-Regular.ttf` at 8pt in the fixed 75pt `ALLOCATED_AMOUNT_WIDTH`: `" (Abschlagszahlung)"` = 72.85pt (the existing sibling badge, so it **sets the real ceiling**), `" (abzgl. Abschlag)"` = 63.95pt, `" (abzgl. Abschlagszahlung)"` = **96.07pt, overflows by ~21pt**. -**The decisive arithmetic the options list missed: option (c), "a shorter compliant string", is unavailable.** `Abschlagszahlung` alone eats 72.85 of 75pt, so **no qualifier of any length fits** — not `abzgl.`, not `ohne`, not the accounting `./.`. Keeping the full term therefore forces dropping the qualifier, collapsing this label into the *constituted*-deposit label (`(Abschlagszahlung)`, #1923 AC2.1). Those are different facts — "this row **is** a deposit" vs "this amount is **reduced because** deposits were claimed separately". **Collapsing them is a worse information loss than the abbreviation.** +**The decisive arithmetic the options list missed: option (c), "a shorter compliant string", is unavailable.** `Abschlagszahlung` alone eats 72.85 of 75pt, so **no qualifier of any length fits** — not `abzgl.`, not `ohne`, not the accounting `./.`. Keeping the full term therefore forces dropping the qualifier, collapsing this label into the _constituted_-deposit label (`(Abschlagszahlung)`, #1923 AC2.1). Those are different facts — "this row **is** a deposit" vs "this amount is **reduced because** deposits were claimed separately". **Collapsing them is a worse information loss than the abbreviation.** Option (b), widening the column, rejected on **risk not cost**: `ALLOCATED_AMOUNT_WIDTH` is #1929 geometry that took **four rounds** and real render-and-rasterize measurement, precisely because Usage was collapsing and the table overflowed the page edge. Reopening it for zero reader benefit is a bad trade. And `Abschlag`/`Abschlagszahlung` are the same concept in German construction practice (cf. `Abschlagsrechnung`); `abzgl. Abschlag` is idiomatic invoice German. No reader is misled — the bar set by the Verwendungsnachweis/Einreichung precedent. -**Why it earns a glossary entry rather than a silent exception: without one a future compliance sweep "fixes" it in good faith and silently breaks a bank-facing PDF, invisibly to the unit suite.** The entry must record the 75pt column and the measurement as the *reason*, not just list the variant. **Generalizable: a deliberate deviation from an approved term needs a recorded reason at the glossary, or the next sweep reverts it.** +**Why it earns a glossary entry rather than a silent exception: without one a future compliance sweep "fixes" it in good faith and silently breaks a bank-facing PDF, invisibly to the unit suite.** The entry must record the 75pt column and the measurement as the _reason_, not just list the variant. **Generalizable: a deliberate deviation from an approved term needs a recorded reason at the glossary, or the next sweep reverts it.** ### Glossary: `split`'s three German forms — NO entry, deliberately -`anteilig` (adj.) / `Anteil` (noun) / `Teilbetrag` (noun), each role-correct, mirroring English's own `partial`/`split`/`portion`. **Ruled: not drift, no entry.** The glossary prevents *semantic* divergence — one concept becoming two concepts. It is **not a single-surface-form registry**, and pinning one form here would force ungrammatical copy across an adjective and two nouns. Recorded on #1917 as "reject if proposed later" so a future translator does not re-escalate. Revisit only if the *English* is unified — a copy story, not a glossary one. +`anteilig` (adj.) / `Anteil` (noun) / `Teilbetrag` (noun), each role-correct, mirroring English's own `partial`/`split`/`portion`. **Ruled: not drift, no entry.** The glossary prevents _semantic_ divergence — one concept becoming two concepts. It is **not a single-surface-form registry**, and pinning one form here would force ungrammatical copy across an adjective and two nouns. Recorded on #1917 as "reject if proposed later" so a future translator does not re-escalate. Revisit only if the _English_ is unified — a copy story, not a glossary one. ### Issues filed from the PR #1959 sweep (2026-08-03) All parentless, Bank Report Wizard cluster. #1959 was the user's own PR and held promotion #1958. -| Issue | Substance | -| --- | --- | +| Issue | Substance | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **#1965** | Report-level, non-editable legend restoring the `(partial)` / `(less deposit)` explanatory sentences (Must Have; producer-only fix — the footnote channel is orphaned, not removed) | -| **#1966** | E2E coverage for the column toggles | -| **#1967** | `attachmentsNote` override is unreachable | -| **#1968** | Meta-suffix emitted as a single run | -| **#1969** | `testPrefix` / `authenticatedPage` fixture cleanup | -| **#1970** | Configurable auth rate limits — see [auth-rate-limits-1970.md](auth-rate-limits-1970.md) | -| **#1971** | `search-users.spec.ts` leftovers | -| **#1972** | Column-preference saves fail silently + dead `isLoaded` | +| **#1966** | E2E coverage for the column toggles | +| **#1967** | `attachmentsNote` override is unreachable | +| **#1968** | Meta-suffix emitted as a single run | +| **#1969** | `testPrefix` / `authenticatedPage` fixture cleanup | +| **#1970** | Configurable auth rate limits — see [auth-rate-limits-1970.md](auth-rate-limits-1970.md) | +| **#1971** | `search-users.spec.ts` leftovers | +| **#1972** | Column-preference saves fail silently + dead `isLoaded` | ## #1973 column visibility (2026-08-03) — user overruled my invented floor -**Requirement**: *"If i de-select a column in the preview it shouldn't render in the pdf"*, then, when asked which columns are mandatory: *"generalize the use case i want to be able to specify an arbitrary amount of columns - only the allocated amount is a mandatory column"*. +**Requirement**: _"If i de-select a column in the preview it shouldn't render in the pdf"_, then, when asked which columns are mandatory: _"generalize the use case i want to be able to specify an arbitrary amount of columns - only the allocated amount is a mandatory column"_. -**My floor was rejected.** I proposed "at least one of Vendor / Invoice # must remain, on row-auditability grounds." The user rejected it as an **invented compliance rule**. Allocated Amount alone is a legal document. **Lesson, generalizable: when I flag "I don't want to invent a compliance rule" and then invent one anyway on plausible-sounding domain reasoning, that is still inventing one.** Allocated Amount's own mandatory status survived only because it rests on two *structural* facts in this codebase (summary-row amounts and the #1959 inline labels both live in that cell), not on a domain claim. **Structural justification survives user scrutiny; purposive domain reasoning does not.** +**My floor was rejected.** I proposed "at least one of Vendor / Invoice # must remain, on row-auditability grounds." The user rejected it as an **invented compliance rule**. Allocated Amount alone is a legal document. **Lesson, generalizable: when I flag "I don't want to invent a compliance rule" and then invent one anyway on plausible-sounding domain reasoning, that is still inventing one.** Allocated Amount's own mandatory status survived only because it rests on two _structural_ facts in this codebase (summary-row amounts and the #1959 inline labels both live in that cell), not on a domain claim. **Structural justification survives user scrutiny; purposive domain reasoning does not.** -**The floor was accidentally protecting something real** — worth remembering as a pattern. `buildSummaryRow` puts the label in the last *leading* cell (before the amount columns). With no leading columns visible the label had nowhere to go and totals would print as bare numbers. Removing the floor promoted that from "impossible by construction" to "must be ruled on": R2 = no total ever prints unlabelled; where no leading cell exists the label goes **in the same cell as the amount**. **When a constraint is removed, re-derive what it was silently guaranteeing — the constraint's *reason* may have been wrong while its *effect* was load-bearing.** +**The floor was accidentally protecting something real** — worth remembering as a pattern. `buildSummaryRow` puts the label in the last _leading_ cell (before the amount columns). With no leading columns visible the label had nowhere to go and totals would print as bare numbers. Removing the floor promoted that from "impossible by construction" to "must be ruled on": R2 = no total ever prints unlabelled; where no leading cell exists the label goes **in the same cell as the amount**. **When a constraint is removed, re-derive what it was silently guaranteeing — the constraint's _reason_ may have been wrong while its _effect_ was load-bearing.** -**Corrected the coordinator's reading of "arbitrary"** (Q5). It read as "the report type's base set is not a ceiling — a claim could re-add Status." Wrong: `buildReportContent.ts:203` sets `status: isOverview ? status : null`, so there is **no status value** for claim/proof-of-funds. Re-adding it renders a column of blanks in a bank document. Ruled: **the base set IS the ceiling, for a data reason not a policy one.** "Arbitrary" frees the subset among columns the report *has*; it does not conjure unproduced data. Named the alternative explicitly (make `buildReportContent` produce status for claims) so the user can request it rather than having me decide. **Generalizable: a user's scope-widening ruling does not implicitly authorize inventing data.** +**Corrected the coordinator's reading of "arbitrary"** (Q5). It read as "the report type's base set is not a ceiling — a claim could re-add Status." Wrong: `buildReportContent.ts:203` sets `status: isOverview ? status : null`, so there is **no status value** for claim/proof-of-funds. Re-adding it renders a column of blanks in a bank document. Ruled: **the base set IS the ceiling, for a data reason not a policy one.** "Arbitrary" frees the subset among columns the report _has_; it does not conjure unproduced data. Named the alternative explicitly (make `buildReportContent` produce status for claims) so the user can request it rather than having me decide. **Generalizable: a user's scope-widening ruling does not implicitly authorize inventing data.** -**Q3 — legend conditional or unconditional?** Ruled **unconditional**, #1965 **blocks** #1973 (`addBlockedBy` set). Hiding Invoice Amount destroys the adjacency that was my *entire* stated reason for accepting bare `(partial)` on PR #1959. Decisive argument against conditionality: **`(less deposit)` was already insufficient regardless of adjacency** (missing word = *separately*), so the legend block must print unconditionally anyway — conditioning the other sentence saves nothing and adds a branch whose output a reader cannot predict. AC 6.1 explicitly forbids implementing it as `if invoiceAmount hidden then legend`. Cross-link comment posted on #1965 so the raised necessity isn't lost. +**Q3 — legend conditional or unconditional?** Ruled **unconditional**, #1965 **blocks** #1973 (`addBlockedBy` set). Hiding Invoice Amount destroys the adjacency that was my _entire_ stated reason for accepting bare `(partial)` on PR #1959. Decisive argument against conditionality: **`(less deposit)` was already insufficient regardless of adjacency** (missing word = _separately_), so the legend block must print unconditionally anyway — conditioning the other sentence saves nothing and adds a branch whose output a reader cannot predict. AC 6.1 explicitly forbids implementing it as `if invoiceAmount hidden then legend`. Cross-link comment posted on #1965 so the raised necessity isn't lost. -**Q4 persistence** — per-session in `ReportWizardPage` state, **not** `useColumnPreferences`. Strengthened by the ruling: 96 legal subsets with no floor means the right set varies per recipient, so a sticky per-user value is wrong more often than right *and* wrong invisibly. Resets on use-case change (third instance of the #1943/#1946 hazard — handled up front, not filed later). +**Q4 persistence** — per-session in `ReportWizardPage` state, **not** `useColumnPreferences`. Strengthened by the ruling: 96 legal subsets with no floor means the right set varies per recipient, so a sticky per-user value is wrong more often than right _and_ wrong invisibly. Resets on use-case change (third instance of the #1943/#1946 hazard — handled up front, not filed later). -**Q7 (new, from the ruling)** — Usage is now hideable and it is the **only elastic column** (`USAGE_WIDTH_*COL` = leftover). Ruled the *observable outcome*, left the mechanism to the architect: width never exceeds `printableWidth()` (unconditional); surplus goes to a free-form text column (Usage, else Vendor); when neither is visible **the table renders narrower than the page, left-aligned** — a 2-column numeric table stretched across 515pt looks broken in a bank document. Degenerate case = one 75pt column. +**Q7 (new, from the ruling)** — Usage is now hideable and it is the **only elastic column** (`USAGE_WIDTH_*COL` = leftover). Ruled the _observable outcome_, left the mechanism to the architect: width never exceeds `printableWidth()` (unconditional); surplus goes to a free-form text column (Usage, else Vendor); when neither is visible **the table renders narrower than the page, left-aligned** — a 2-column numeric table stretched across 515pt looks broken in a bank document. Degenerate case = one 75pt column. -**Geometry facts pinned** (`overviewPdf.ts` / `pageGeometry.ts`): fixed widths Vendor 45, Invoice # 63, Date 46, Status 40, InvoiceAmount 48, Allocated 75; Usage = `usableColumnWidth(n) - fixedSum`. Subsets: overview 2^6=**64**, claim 2^5=**32**, **96 total**, counts 1–7. **Hiding a column can only make Usage *wider*** → per-line char counts rise, row heights fall, so every measurement-pinned bound moves in the *safe* direction (exception: hiding Usage itself). Noted in AC 3.6 as a mitigating fact for the implementer. +**Geometry facts pinned** (`overviewPdf.ts` / `pageGeometry.ts`): fixed widths Vendor 45, Invoice # 63, Date 46, Status 40, InvoiceAmount 48, Allocated 75; Usage = `usableColumnWidth(n) - fixedSum`. Subsets: overview 2^6=**64**, claim 2^5=**32**, **96 total**, counts 1–7. **Hiding a column can only make Usage _wider_** → per-line char counts rise, row heights fall, so every measurement-pinned bound moves in the _safe_ direction (exception: hiding Usage itself). Noted in AC 3.6 as a mitigating fact for the implementer. **#1966 closed as superseded, not amended** (board Wont-Do, supersession comment with an AC→AC carry-forward table). Its Notes asserted "nothing about them should reach the generated PDF" — the deleted premise — and its AC1 asserted DOM removal only, which **would pass while the PDF still contained every column**. Amending would have left a tech-debt/test-only issue carrying a functional change and erased the record of the reversal. **Rule: when a user reverses a design decision, close the issue built on the old premise and carry its still-valid ACs forward with attribution — don't rewrite it.** -**Sequencing: after the #1958 promotion.** Stated as my own opinion, not deferred. #1958 is green/CLEAN at 54 commits with #1959 in it; this blocks on #1965 anyway; and it generalizes the exact module that produced two real defects that day. The ruling made the surface *larger* (no floor → degenerate single-column geometry + summary-label relocation). The shipped hint is **honest** — a stale string is cheap to reverse, a malformed bank document is not. +**Sequencing: after the #1958 promotion.** Stated as my own opinion, not deferred. #1958 is green/CLEAN at 54 commits with #1959 in it; this blocks on #1965 anyway; and it generalizes the exact module that produced two real defects that day. The ruling made the surface _larger_ (no floor → degenerate single-column geometry + summary-label relocation). The shipped hint is **honest** — a stale string is cheap to reverse, a malformed bank document is not. ### #1973 rev 3 — spec reconciliation, and a stale-body process failure -**Process failure worth avoiding: I rewrote the #1973 body (rev 1 → rev 2) but only reported the *rulings* in my handback, not "the body has been rewritten and the numbering changed."** The coordinator and `dev-team-lead` both then worked from a cached rev 1, and the dev-team-lead filed the 1-column floor as a *contradiction to be fixed* when it had already been fixed. Substance never diverged; only R/AC numbers did (rev 2 reassigned R1–R8 and the AC numbers wholesale). **Rule: when amending an already-reported issue body, say "body rewritten, numbering reassigned" explicitly in the handback, and put an amendment log in the issue's Notes.** #1973 now carries one. +**Process failure worth avoiding: I rewrote the #1973 body (rev 1 → rev 2) but only reported the _rulings_ in my handback, not "the body has been rewritten and the numbering changed."** The coordinator and `dev-team-lead` both then worked from a cached rev 1, and the dev-team-lead filed the 1-column floor as a _contradiction to be fixed_ when it had already been fixed. Substance never diverged; only R/AC numbers did (rev 2 reassigned R1–R8 and the AC numbers wholesale). **Rule: when amending an already-reported issue body, say "body rewritten, numbering reassigned" explicitly in the handback, and put an amendment log in the issue's Notes.** #1973 now carries one. -**Adopted the spec's answer over my own on the summary label.** I ruled "label in the same cell as the amount"; the spec's **three-tier fallback** is better and is now R2 + AC 4.6: last visible leading column (**92**/96 subsets) → Invoice Amount if visible → **separate two-column block beneath the table** (**4** subsets: `{allocatedAmount}` and `{allocatedAmount, usage}` × 2 use cases). Verified the parity argument on disk: `ReportContentEditor.tsx:442-445` renders `content.summaryRows` as its own block independent of column visibility, so the PDF *matches* the HTML preview exactly where in-table placement is impossible. **Tier 3 increases preview parity rather than costing it — a fallback that converges on the existing preview is strictly better than one that invents a new form.** +**Adopted the spec's answer over my own on the summary label.** I ruled "label in the same cell as the amount"; the spec's **three-tier fallback** is better and is now R2 + AC 4.6: last visible leading column (**92**/96 subsets) → Invoice Amount if visible → **separate two-column block beneath the table** (**4** subsets: `{allocatedAmount}` and `{allocatedAmount, usage}` × 2 use cases). Verified the parity argument on disk: `ReportContentEditor.tsx:442-445` renders `content.summaryRows` as its own block independent of column visibility, so the PDF _matches_ the HTML preview exactly where in-table placement is impossible. **Tier 3 increases preview parity rather than costing it — a fallback that converges on the existing preview is strictly better than one that invents a new form.** -**AC 3.7, the one-sided chunk-budget clamp — a real correction to my AC.** My 3.6 said "recompute `MAX_SAFE_USAGE_CHUNK_CHARS` from the subset's actual Usage width", which implies upward scaling is legitimate. It is not. Hiding columns only *widens* Usage (chars/line 16 → up to 50), so the 650 budget gets more conservative and this change cannot breach it. **The hazard is the opposite and arrives later: a future *added* column narrows Usage, drops the true ceiling below 650, and silently reinstates the #1929 content-loss defect.** 650 rests on a **single real-render measurement at one width**; extrapolating upward is what caused the round-3 defect. So the clamp **scales down, never up**, AC 3.7 requires a test for *both* directions, and a comment must record the asymmetry as deliberate so it isn't "optimised" away as dead code. **Generalizable: a bound pinned by one measurement may be scaled toward safety but never away from it — and one-sided clamps need a recorded reason or they read as bugs.** +**AC 3.7, the one-sided chunk-budget clamp — a real correction to my AC.** My 3.6 said "recompute `MAX_SAFE_USAGE_CHUNK_CHARS` from the subset's actual Usage width", which implies upward scaling is legitimate. It is not. Hiding columns only _widens_ Usage (chars/line 16 → up to 50), so the 650 budget gets more conservative and this change cannot breach it. **The hazard is the opposite and arrives later: a future _added_ column narrows Usage, drops the true ceiling below 650, and silently reinstates the #1929 content-loss defect.** 650 rests on a **single real-render measurement at one width**; extrapolating upward is what caused the round-3 defect. So the clamp **scales down, never up**, AC 3.7 requires a test for _both_ directions, and a comment must record the asymmetry as deliberate so it isn't "optimised" away as dead code. **Generalizable: a bound pinned by one measurement may be scaled toward safety but never away from it — and one-sided clamps need a recorded reason or they read as bugs.** **Verified geometry figures** (taken as computed from the spec, not re-derived): **72** of 96 subsets equal `printableWidth()` (48 overview + 24 claim); **24** render narrower (16 + 8), totals **84.00pt** (`tableOffsetsTotal(1)` 9.00 + 75) to **315.00pt** (`tableOffsetsTotal(5)` 43.00 + 272). I independently spot-checked both endpoints and the 72/24 split against the constants and they hold. **Q5/R6 confirmed by the coordinator** ("your Q5 correction was right and I was wrong") — the type's base set is the ceiling for the `status: isOverview ? status : null` **data** reason. Note it is **R6** in rev 2+, not R7 as in rev 1. + +### PR #2004 review — #1888 accepted, #1910 rejected on AC3 (2026-08-05) + +**#1888 shipped clean** as a step-3 helper line (AC4's second branch), not per-row accessible naming: `sourceReports.attachmentsNote` interpolating `t('sourceReports.useCase.${report.type}')`. Two checks that made it an accept rather than a "probably fine": all three `SourceReportType` values have `useCase.*` keys in **both** locales (no raw-key fallback for any report type), and the render gate `allocatedInvoices.length > 0` coincides **exactly** with the rows that render an indicator — unallocated rows render none — so there is no state with an unexplained paperclip and none with an explanation and nothing to explain. **When an AC is satisfied by a page-level helper line rather than per-item labelling, the finding to hunt is the gate: does the line appear in every state where the thing it explains is visible, and only those?** + +**#1910 AC3 failed — the "blanket-tag + partial counter-tag" antipattern.** The PR put `lang=""` on `ReportContentEditor`'s `.container` and counter-tagged only the `

    `s and the column-toggle hint `

    `. AC3 enumerates "editable-field labels, **buttons**, headings"; `EditableField`'s visible `

    {row.vendor}
    `s, column-toggle label text, mobile-card captions, source-info block, deposit/split notes) — server-generated report content. `ReportPdfPreview.tsx` untouched and renders an `