Skip to content

fix(i18n): route all date/number display through locale-aware formatters - #1845

Merged
steilerDev merged 3 commits into
betafrom
fix/1813-formatters-locale
Jul 7, 2026
Merged

fix(i18n): route all date/number display through locale-aware formatters#1845
steilerDev merged 3 commits into
betafrom
fix/1813-formatters-locale

Conversation

@steilerDev

Copy link
Copy Markdown
Owner

Summary

  • Reroutes 14 raw toLocale*/Intl.*/display-toFixed call sites across 10 components (documents, Gantt tooltip/header, mini-Gantt, signature capture, backups, budget sources, diary) through client/src/lib/formatters.ts, fixing en-US-pinned dates, decimal separators, and weekday labels for German-locale users.
  • Extends formatters.ts with formatDate's monthStyle option, formatPercent's locale+digits options, and 4 new locale-aware helpers (formatWeekdayShort, formatWeekdayMonthDay, formatFileSize, formatHours, formatDateTimeWithZone), all exposed via useFormatters(). Adds a locale param to calendarUtils.ts's formatDateForAria, matching its sibling getMonthName/getDayName convention.
  • Bonus fix: DocumentCard/DocumentDetailPanel previously called new Date(document.created).toLocaleDateString(...) directly on a YYYY-MM-DD-only date string, which parses as UTC midnight and can render the wrong calendar day for users behind UTC in the evening. Routing through formatDate (which parses date components directly to avoid this shift) fixes locale and this latent off-by-one-day bug.
  • Fixes a bug found during QA: BudgetSourcesPage destructured formatPercent: _formatPercent (unused-var alias) while new tooltip code referenced the bare formatPercent binding, causing a ReferenceError on segment hover.
  • Adds a new direct unit test file for SignatureCapture (closing a pre-existing test-file-parity gap, 40 tests) plus de-DE locale coverage across all touched components.

Fixes #1813

Test plan

  • Unit tests pass — 872 tests across all 15 touched/new test suites verified locally (formatters, calendarUtils, SignatureCapture, DocumentCard/DetailPanel, GanttTooltip/Header, MiniGanttCard, BackupsPage, SourceUtilizationCard, BudgetSourcesPage, DiaryEntryForm, DiaryMetadataSummary, MonthGrid, WeekGrid, DateRangePicker)
  • npx tsc -p client/tsconfig.json --noEmit clean (full-program check, catches the formatPercent ReferenceError class ts-jest's isolated compilation missed)
  • Lint clean on all touched files (8 pre-existing errors / 50 pre-existing warnings elsewhere in the repo, unrelated to this change — verified via git stash diff against origin/beta)
  • CI Quality Gates

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) noreply@anthropic.com
Co-Authored-By: Claude frontend-developer (Haiku 4.5) noreply@anthropic.com
Co-Authored-By: Claude translator (Sonnet 4.5) noreply@anthropic.com
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) noreply@anthropic.com

steilerDev and others added 3 commits July 7, 2026 19:50
Sweep of 14 raw toLocale*/Intl./toFixed call sites across 10 components that
bypassed client/src/lib/formatters.ts, rendering en-US dates, decimal
separators, and weekday labels for German-locale users regardless of their
selected locale.

Extends formatters.ts with formatDate's monthStyle option, formatPercent's
locale+digits options, and four new locale-aware helpers (formatWeekdayShort,
formatWeekdayMonthDay, formatFileSize, formatHours, formatDateTimeWithZone),
each exposed through useFormatters(). Reroutes GanttTooltip/GanttHeader,
MiniGanttCard, SignatureCapture's canvas-burned timestamp, BackupsPage file
sizes, SourceUtilizationCard/BudgetSourcesPage percentages, and diary work
durations through the shared formatters. Adds a locale param to
calendarUtils.ts's formatDateForAria, matching its sibling getMonthName/
getDayName convention.

Bonus fix: DocumentCard and DocumentDetailPanel previously called
`new Date(document.created).toLocaleDateString(...)` directly on a
YYYY-MM-DD-only date string, which parses as UTC midnight and can render the
wrong calendar day for users behind UTC in the evening. Routing these through
formatDate (which parses date components directly to avoid this shift) fixes
locale and this latent off-by-one-day bug.

Also fixes a bug found during QA: BudgetSourcesPage destructured
`formatPercent: _formatPercent` (unused-var alias) while the new tooltip code
referenced the bare `formatPercent` binding, causing a ReferenceError on
segment hover. Adds a new direct unit test file for SignatureCapture (closing
a pre-existing test-file-parity gap) and de-DE locale coverage across all
touched components.

Fixes #1813

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude translator (Sonnet 4.5) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
…dependencies

GanttChart.test.tsx, DocumentBrowser.test.tsx, and DiaryEntryEditPage.test.tsx
render DocumentCard/GanttHeader/DiaryEntryForm without a LocaleProvider (or,
for GanttChart, with a formatters.js mock missing the new
formatWeekdayMonthDay export). Those components now depend on locale context
per the #1813 formatter consolidation, which their existing test setups
didn't anticipate, failing Quality Gates' test job (shards 2/3/5).

Adds a fixed-locale LocaleContext.js mock to DocumentBrowser.test.tsx and
DiaryEntryEditPage.test.tsx (matching GanttChart.test.tsx's existing
pattern), and adds the missing formatWeekdayMonthDay stub to GanttChart's
formatters.js mock. Test-only change, no production code touched.

Fixes #1813

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
InvoicePaperlessPickerModal.test.tsx renders the embedded DocumentBrowser ->
DocumentCard (a grandparent consumer, two hops from the directly-changed
component), which the prior consumer-blast-radius fix missed since it only
checked direct importers. Adds the same LocaleContext.js mock block used in
DocumentBrowser.test.tsx to unblock DocumentCard's useFormatters() call.

Followed up with a full transitive-closure walk (grep for importers,
recursively, to fixpoint) across every remaining consumer of the 11
components changed in #1813: LinkedDocumentsSection, LinkedDocumentCard,
DocumentBrowser, GanttChart, CalendarView, DashboardPage, SignatureSection,
DiaryEntryEditPage, DiaryEntryDetailPage, DiaryEntryCard,
HouseholdItemDetailPage, WorkItemDetailPage, InvoiceDetailPage,
SubsidyProgramsPage, TimelinePage, DiaryPage, BudgetSourcesPage,
BackupsPage, and App.test.tsx — 841 tests total, all green, no further gaps.
Test-only change, no production code touched.

Fixes #1813

Co-Authored-By: Claude dev-team-lead (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.5) <noreply@anthropic.com>
@steilerDev

Copy link
Copy Markdown
Owner Author

[security-engineer] Security review of #1845 (formatter consolidation, fix/1813-formatters-locale).

Scope confirmed: git diff origin/beta..HEAD touches only client/ and CLAUDE.md. No changes under server/, shared/, package.json, or package-lock.json — no new dependencies, no server-side surface.

Reviewed areas:

  1. New/modified formatters (client/src/lib/formatters.ts): formatPercent, formatDate, formatWeekdayShort, formatWeekdayMonthDay, formatFileSize, formatHours, formatDateTimeWithZone are all thin wrappers around Intl.NumberFormat / Date.prototype.toLocaleDateString / toLocaleString. Inputs are number or Date — no string concatenation into locale/format option objects, no template-literal injection into Intl constructor arguments.

  2. Locale parameter provenance: All 15 call sites pass locale/localeString sourced from LocaleContext, which is typed as LocalePreference = 'en' | 'de' | 'system' (client/src/contexts/LocaleContext.tsx:14) — a closed enum, not free-form user input. No path for a user-controlled string to reach Intl.NumberFormat(locale, ...).

  3. SignatureCapture (client/src/components/diary/SignatureCapture/SignatureCapture.tsx:301): the diff only swaps an inline toLocaleString(...) call for formatDateTimeWithZone(now), where now is a Date object (not user input). The surrounding labelText = \${displayName} — ${formattedDate}`concatenation is pre-existing and untouched by this PR — canvasfillText` renders to pixel data, not DOM/HTML, so no XSS vector regardless.

  4. i18n key edits (dashboard.json en/de): "{{percent}}% utilized""{{percent}} utilized" — cosmetic, moves the % into the already-locale-formatted value. No injection concern (i18next interpolation, not raw HTML).

  5. Dependencies: none added/changed. Confirmed via diff stat — no package.json/package-lock.json touched.

No injection surface, no auth/authz-relevant code, no sensitive data exposure, no new dependencies. This is a pure client-side display-formatting refactor.

Verdict: APPROVED

@steilerDev

Copy link
Copy Markdown
Owner Author

[product-architect] APPROVED

Reviewed the formatter consolidation against the areas I own (API design, conventions, no server/shared contract impact). Verdict: approve — findings are informational only.

Verified

  • No server/shared impact. Diff is entirely under client/ plus a CLAUDE.md convention line and two i18n value edits. No schema, API contract, or shared-type surface touched — nothing for me to reconcile against the wiki.
  • Formatters stay pure & testable. All new helpers (formatWeekdayShort, formatWeekdayMonthDay, formatFileSize, formatHours, formatDateTimeWithZone) are side-effect-free module functions taking (value, locale); the React binding lives only in the useFormatters() wrapper, which is the correct seam. formatters.test.ts exercises them directly with explicit locales (incl. de-DE), which is exactly why keeping locale as a param rather than reading context inside the fn matters.
  • Backward-compatible signatures. formatDate gains monthStyle as a trailing optional param (raw (dateStr, locale, fallback, monthStyle); hook (dateStr, fallback?, monthStyle?)) and formatPercent gains trailing locale+digits — existing call sites keep working untouched. Good discipline appending rather than reordering.
  • formatPercent Intl migration. Defaults (en-US, 2 digits) are byte-identical to the old toFixed(2) for typical percentages, and the move to Intl.NumberFormat is what makes de-DE decimal separators correct. The paired i18n edit (moving the literal % out of {{percent}} utilized in both en/de, since the sign is now inside formatPercent's output) is consistent.
  • Module split is right. formatDateForAria correctly stays in calendarUtils.ts alongside getMonthName/getDayName (calendar-domain, UTC-parsing YYYY-MM-DD) rather than migrating to the general formatters.ts; its new trailing locale param matches its siblings' convention. Generic display formatters live in formatters.ts. Clean separation.
  • CLAUDE.md convention update accurately reflects the new public formatters and preserves the "never raw toLocaleDateString/Intl.NumberFormat" rule.

Informational (non-blocking, no action required)

  1. Locale-param position is split two ways across the module: single-option formatters put locale last (formatWeekdayShort(date, locale)), while multi-option ones keep it second (formatDate(dateStr, locale, fallback, monthStyle), formatPercent(rate, locale, digits)). Defensible — the multi-option ones can't reorder existing params without a breaking change — but worth a one-line note in the JSDoc if this ever gets a follow-up, so future helpers pick the right slot.
  2. formatDate is now 4 positional params mixing locale/fallback/style. Still readable, but this is the point where an options object ({ locale, fallback, monthStyle }) starts to earn its keep. Not worth churning now given the hook wrapper hides it from consumers; flag only if a 5th option appears.
  3. Intl.NumberFormat adds grouping separators that toFixed did not — a percentage >= 1000 would render 1,000.00% vs the old 1000.00%. Not reachable for utilization/rate values in practice; noting for completeness only.

Not re-flagging the known follow-ups (DateRangePicker locale-vs-resolvedLocale, SignatureCapture error toFixed, ESLint ban rule) per the review scope.

@steilerDev

Copy link
Copy Markdown
Owner Author

[ux-designer] Design/i18n-output review of #1845.

Reviewed scope: client/src/lib/formatters.ts and every touched call site (GanttHeader, GanttTooltip, MiniGanttCard, SourceUtilizationCard, calendarUtils.formatDateForAria + MonthGrid/WeekGrid/DateRangePicker, DiaryEntryForm, DiaryMetadataSummary, SignatureCapture, DocumentCard, DocumentDetailPanel, BackupsPage, BudgetSourcesPage, dashboard.json).

Locale-output spot-checks (script-verified with node/Intl, not eyeballed — per the #1844 lesson):

Formatter en-US de-DE
formatDate('2026-02-24') Feb 24, 2026 24. Feb. 2026
formatDate(..., 'long') Feb 24, 2026/February 24, 2026 24. Februar 2026
formatWeekdayShort Tue Di
formatWeekdayMonthDay Tue, Feb 24 Di., 24. Feb.
formatFileSize(1536) 1.5 KB 1,5 KB
formatHours(7.5) 7.50 h 7,50 h
formatDateForAria Tuesday, February 24, 2026 Dienstag, Februar 24, 2026
formatDateTimeWithZone Feb 24, 2026, 02:45 PM GMT+2 24. Feb. 2026, 14:45 GMT+2

All match the PR's own new test assertions in formatters.test.ts, calendarUtils.test.ts, GanttHeader.test.tsx, GanttTooltip.test.tsx, MiniGanttCard.test.tsx, DocumentCard.test.tsx, SignatureCapture.test.tsx — no discrepancies found between claimed and actual Intl output.

English non-regression at touched sites:

  • formatDate byte-matches the removed inline toLocaleDateString calls at every site (Gantt tooltip dates, DocumentCard/DocumentDetailPanel) — key ordering in the Intl options object doesn't affect output, confirmed.
  • formatWeekdayShort/formatWeekdayMonthDay reproduce the prior hardcoded 'en-US' output exactly when localeString === 'en-US' (the app default).
  • formatDateForAria('...', 'en-US') is byte-identical to the old no-arg call — confirmed via the PR's own "explicit en-US locale behaves identically to the default" test and independently in node.
  • Document date off-by-one fix: confirmed the mechanism — old code ran new Date(document.created) (parses as a UTC instant when ISO-with-timezone) then .toLocaleDateString('en-US') in the browser's local zone, which can roll the date across midnight for non-UTC users. New formatDate slices YYYY-MM-DD and builds a local-midnight Date, sidestepping the UTC→local conversion entirely. This is a real, deliberate bug fix, not a regression.
  • SourceUtilizationCard "utilized" template: formatPercent(x, 0) + "{{percent}} utilized" reproduces "{{percent}}% utilized" with toFixed(0) for realistic values (0–100%, even up to ~999%). Confirmed both disclosed exceptions (document dates, utilized template) are the only two English-rendering deviations in this diff — no other hardcoded-string or template site changed its English output.

One informational, non-blocking finding: formatPercent's Intl.NumberFormat grouping means percentages ≥ 1000 now render with a thousands separator in English ("1,500%" vs. old "1500%"), a genuine third English-output edge case not mentioned in the PR's two disclosed exceptions. It only affects the SourceUtilizationCard sr-only announcement (not visible text) and only fires when a budget source is used at ≥10x its total amount — an extreme, arguably-already-broken state. Not blocking; flagging for awareness only.

Aria-label grammar (formatDateForAria): confirmed the German output keeps English sentence order ("Dienstag, Februar 24, 2026" instead of the grammatically correct "Dienstag, 24. Februar 2026"). Per the task brief this is a known, accepted, deferred gap — screen reader output is still comprehensible, not blocking.

Visible-text change surface: Limited to exactly the two declared cases (document dates, SourceUtilizationCard percent-in-template). Everything else is either (a) previously-hardcoded-en-US output that now correctly reads app locale, with English output unchanged, or (b) new formatters (formatFileSize, formatHours, formatDateTimeWithZone) applied to values that weren't locale-aware before, again with English output preserved (formatFileSize/formatHours verified byte-identical to old toFixed/manual string-building for realistic value ranges).

Test coverage: Every touched call site has both an explicit de-DE assertion and an explicit "no regression in en-US" assertion (e.g. GanttHeader.test.tsx "day zoom cell aria-label still renders correctly for 'en' locale (no regression)"). This is good practice — no design/i18n gaps found in coverage.

Verdict: APPROVED. No token, dark-mode, accessibility, or English-rendering regressions found. Locale outputs verified correct for all new formatters. The one thousands-separator edge case and the pre-existing aria grammar-order gap are both informational/non-blocking.

Note: E2E Gates shows failures (shards 3/4/10/14) but Quality Gates (the required check for beta) is green; this review is scoped to the client design/i18n diff, not CI triage.

@steilerDev
steilerDev merged commit 8ef1aaf into beta Jul 7, 2026
27 of 32 checks passed
@steilerDev
steilerDev deleted the fix/1813-formatters-locale branch July 7, 2026 18:40
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.13.0-beta.14 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.13.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant