Files
tessera-ctl/.planning/phases/14-rss-email-alert-ingestion-module-rollout/14-05-SUMMARY.md
T
schalli eff5d26413
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 55s
Tessera CI/CD / Build & Publish Images (push) Successful in 1m51s
docs(14-05): complete i18n rollout for tender-radar plan
2026-07-23 14:23:23 +02:00

189 lines
15 KiB
Markdown

---
phase: 14-rss-email-alert-ingestion-module-rollout
plan: 05
subsystem: ui
tags: [i18n, next-intl, react, tender-radar]
# Dependency graph
requires:
- phase: 14-rss-email-alert-ingestion-module-rollout
provides: RssFeedListForm (14-02), EmailAlertConfigForm (14-03), CoverageBanner denylist block (14-04)
provides:
- tenderRadar i18n namespace (DE + EN) covering the entire tender-radar module UI
- de/en key-parity guard test (tenderRadar-parity.spec.ts)
- All tender-radar components/pages converted from hardcoded German to useTranslations('tenderRadar')
affects: [any future tender-radar UI work, platform-wide i18n rollout for other modules]
# Tech tracking
tech-stack:
added: []
patterns:
- "Module i18n namespace: top-level {moduleName} key in de.json/en.json, grouped by component (results.*, filter.*, savedSearch.*, detail.*, coverage.*, settings.*, sourceConfig.*, rssFeeds.*, emailAlerts.*)"
- "de/en key-parity spec: recursively flatten both locale JSON objects under the namespace and assert identical sorted key sets — catches any future one-sided edit"
- "Filter option label translation while preserving the backend-matched data value: FilterPanel's Bundesland/CPV constants keep a stable `code` (NUTS-1 prefix / CPV division code) used only as the i18n lookup key; the actual filter `value` sent to the API stays the untranslated canonical string"
- "Test mock convention: vi.mock('next-intl', () => ({ useTranslations: () => (key, params) => lookupTable[key] ?? key })) — mirrors apps/web's existing marketplace test convention (tenant-selector.test.tsx)"
key-files:
created:
- apps/web/src/messages/tenderRadar-parity.spec.ts
modified:
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/app/(portal)/modules/tender-radar/page.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx
- apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx
- apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx
key-decisions:
- "Proper-noun portal display labels (DÖE, DTVP, tender24, lhs-vpbw, vergabe.landbw) in TenderDetail.portalLabel() are left as hardcoded string constants, not routed through t() — they are brand/identifier abbreviations, not translatable prose, matching the same treatment protocol names (IMAP, STARTTLS, SSL-TLS) already receive"
- "FilterPanel's Bundesland option *value* (the string sent to the backend filter query) stays the canonical German name unchanged — apps/api's nutsPrefixFor() matches against that exact string server-side and D-10 does not touch the API contract; only the rendered *label* is translated, keyed by the NUTS-1 prefix code (DE1..DEG)"
- "CPV division labels (44 EU CPV vocabulary division descriptions) were moved into the tenderRadar namespace and translated with official EU CPV English division titles — these are genuine multi-word German prose that would otherwise fail the plan's grep-based German-literal audit even though they were originally embedded as 'constant data' rather than typical UI copy"
- "Date formatting (toLocaleDateString('de-DE')) in ResultsList/TenderDetail is left unchanged regardless of locale — plan explicitly scoped this as a 'string-source swap only', not a full locale-aware formatting upgrade"
requirements-completed: [CONFIG-03]
coverage:
- id: D1
description: "tenderRadar i18n namespace added to de.json + en.json with EN translations authored by Claude, enforced by tenderRadar-parity.spec.ts"
requirement: "CONFIG-03"
verification:
- kind: unit
ref: "apps/web/src/messages/tenderRadar-parity.spec.ts#tenderRadar i18n namespace — de/en key parity"
status: pass
human_judgment: false
- id: D2
description: "Results-side components (page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail, CoverageBanner) converted to useTranslations('tenderRadar'); no hardcoded user-facing German text remains"
requirement: "CONFIG-03"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx (8 tests)"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx (9 tests)"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx (5 tests)"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx (4 tests)"
status: pass
human_judgment: false
- id: D3
description: "Settings surface (settings/page.tsx, SourceConfigForm, RssFeedListForm, EmailAlertConfigForm) converted to useTranslations('tenderRadar')"
requirement: "CONFIG-03"
verification:
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx (4 tests)"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx (6 tests)"
status: pass
- kind: unit
ref: "apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx (5 tests)"
status: pass
human_judgment: false
- id: D4
description: "No language-switcher UI element introduced (D-11) — locale continues to follow the existing next-intl NEXT_LOCALE cookie mechanism, unchanged"
verification: []
human_judgment: true
rationale: "Absence-of-a-feature is a structural/visual claim best confirmed by a human scan of the diff rather than an automated assertion; no new UI surface was added anywhere in this plan's file list."
# Metrics
duration: ~50min
completed: 2026-07-23
status: complete
---
# Phase 14 Plan 05: i18n Rollout for Tender Radar Summary
**New `tenderRadar` i18n namespace (DE+EN, ~140 keys) with a de/en key-parity guard test; all 10 tender-radar components/pages converted from hardcoded German to `useTranslations('tenderRadar')`.**
## Performance
- **Duration:** ~50 min
- **Tasks:** 3
- **Files modified:** 19 (2 new, 17 modified)
## Accomplishments
- Added a `tenderRadar` top-level namespace to `apps/web/src/messages/de.json` and `en.json`, grouped by component (`page`, `results`, `filter`, `savedSearch`, `detail`, `coverage`, `settings`, `sourceConfig`, `rssFeeds`, `emailAlerts`) — roughly 140 leaf keys per locale including all 16 Bundesland labels and all 44 EU CPV division descriptions, both faithfully translated to English with consistent Vergabe-domain terminology (Ausschreibung→tender, Vergabestelle→contracting authority, Frist→deadline, Auftragswert→estimated value, Bundesland→federal state).
- Created `tenderRadar-parity.spec.ts` — a structural test that recursively flattens both locale JSON objects under `tenderRadar` and asserts identical sorted key sets (plus a non-empty-string leaf-value check), failing loudly on any future one-sided edit.
- Converted 10 components/pages (page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail, CoverageBanner, settings/page.tsx, SourceConfigForm, RssFeedListForm, EmailAlertConfigForm) to `useTranslations('tenderRadar')` — string-source swap only, no behavior or markup changes.
- Updated 7 affected component test files with a `next-intl` `useTranslations` mock (flat key → German-copy lookup table, with `{param}` interpolation support where needed) mirroring the existing `apps/web` marketplace test convention (`tenant-selector.test.tsx`).
- No language switcher was added anywhere (D-11) — locale continues to follow the existing `NEXT_LOCALE` cookie mechanism in `apps/web/src/i18n/request.ts`, unchanged.
## Task Commits
1. **Task 1: tenderRadar namespace (DE + EN) + de/en key-parity spec** - `d73171b` (feat)
2. **Task 2: Convert results-side components to useTranslations** - `2ea02a2` (feat)
3. **Task 3: Convert settings + config forms to useTranslations** - `849aa9b` (feat)
**Plan metadata:** _pending — this commit_
## Files Created/Modified
- `apps/web/src/messages/de.json` - Added `tenderRadar` namespace (German source-of-truth copy)
- `apps/web/src/messages/en.json` - Added `tenderRadar` namespace (English translations)
- `apps/web/src/messages/tenderRadar-parity.spec.ts` - de/en key-parity + non-empty-leaf-value guard test
- `apps/web/src/app/(portal)/modules/tender-radar/page.tsx` - Page heading + Suspense fallback via `t('page.*')`
- `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx` - Table headers, empty/error states, triage toggle labels, pagination via `t('results.*')`; fixed an unrelated `t` parameter name collision with the translations function
- `apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx` - All filter labels/placeholders via `t('filter.*')`; Bundesland/CPV option labels translated by stable code while filter values stay canonical German strings for backend compatibility
- `apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx` - Saved-search UI, rename/delete/instant-alert controls via `t('savedSearch.*')` with `{name}` interpolation
- `apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx` - Detail drawer fields, source-link copy via `t('detail.*')` with `{portal}` interpolation
- `apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx` - Coverage note + denylist block via `t('coverage.*')`
- `apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx` - Settings page headings, section bodies, digest interval selector via `t('settings.*')`
- `apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx` - Poll-interval config form via `t('sourceConfig.*')` with `{min}/{max}` interpolation
- `apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx` - RSS feed CRUD list via `t('rssFeeds.*')` with `{label}` interpolation
- `apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx` - Per-tenant mailbox config form via `t('emailAlerts.*')`
- 7 corresponding `*.test.tsx` files updated with `next-intl` mocks
## Decisions Made
- Portal display slugs in `TenderDetail.portalLabel()` (DÖE, DTVP, tender24, etc.) are left untranslated as brand/identifier proper nouns, not UI copy.
- Bundesland/CPV filter *values* sent to the API remain the canonical German strings the backend already matches against (`nutsPrefixFor()` in `apps/api`); only the rendered *labels* are translated, keyed by stable codes.
- CPV division descriptions (44 entries) and Bundesland names (16 entries) were moved into the i18n namespace and fully translated — they are genuine multi-word prose embedded in the component source, not just internal identifiers, so leaving them hardcoded would have both violated the plan's literal "no German string literal remains" acceptance criterion and defeated the purpose of a bilingual filter panel.
- Date formatting (`toLocaleDateString('de-DE')`) is intentionally left unchanged regardless of locale — out of scope per the plan's "string-source swap only" directive.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed a `t` parameter name shadowing the translations function in ResultsList**
- **Found during:** Task 2 (ResultsList.tsx conversion)
- **Issue:** `const ids = data.items.map((t) => t.id);` reused the identifier `t` as the map callback parameter, shadowing the outer `useTranslations()` result within that scope — harmless at runtime (the shadow was locally scoped and unused for translation lookups inside that callback) but a latent readability/maintenance hazard once `t` became a load-bearing translation function in this file.
- **Fix:** Renamed the map parameter to `item`.
- **Files modified:** `apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx`
- **Verification:** `npx vitest run` for ResultsList.test.tsx (8/8 pass), `npx tsc --noEmit` clean
- **Committed in:** `2ea02a2` (Task 2 commit)
---
**Total deviations:** 1 auto-fixed (1 bug/hygiene)
**Impact on plan:** No scope creep — purely a readability fix triggered directly by the i18n conversion introducing a load-bearing `t` in the same file.
## Issues Encountered
- Initial `ResultsList` conversion added `t` to a `useCallback` dependency array; the real `next-intl` `useTranslations()` hook returns a stable reference, but the test's inline mock (`() => (key) => ...`) creates a new function identity on every render, causing an extra `listTenders()` invocation and one test failure (`toHaveBeenCalledTimes(1)` got `2`). Resolved by removing `t` from the dependency array (with an `eslint-disable-next-line react-hooks/exhaustive-deps` comment) since the callback only needs `t` for a static fallback-error string, not the effect's re-run condition — matches the codebase's existing convention of selectively suppressing exhaustive-deps for intentionally-once effects (already used elsewhere in this file and in SavedSearchBar/RssFeedListForm/SourceConfigForm/settings-page mount effects).
## Next Phase Readiness
- This is the last plan of Phase 14. The entire tender-radar module (results, filters, saved searches, detail, coverage, settings, RSS/email admin forms) now honors the DE/EN locale via the existing `NEXT_LOCALE` cookie mechanism, with no visible language switcher (D-11, deferred to a future platform-wide i18n phase per 14-CONTEXT.md's Deferred Ideas).
- Full `apps/web` test suite is green: 151/151 (148 pre-existing + 3 new parity tests). `tsc --noEmit` clean.
- No blockers for phase closure. Phase-level verification (`gsd-verify-work` / phase audit) can proceed.
---
*Phase: 14-rss-email-alert-ingestion-module-rollout*
*Completed: 2026-07-23*
## Self-Check: PASSED
All 13 key created/modified files verified present on disk. All 3 task commit hashes (`d73171b`, `2ea02a2`, `849aa9b`) verified present in `git log --oneline --all`.