Skip to content

Release: develop -> main - #1229

Merged
TaprootFreak merged 2 commits into
mainfrom
develop
Aug 1, 2026
Merged

Release: develop -> main#1229
TaprootFreak merged 2 commits into
mainfrom
develop

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Automatic Release PR

This PR was automatically created after changes were pushed to develop.

Commits: 1 new commit(s)

Checklist

  • Review all changes
  • Verify CI passes
  • Approve and merge when ready for production

…locale (#1222)

* fix(format): render dates in Swiss notation outside the Safe

Date formatting is a Swiss convention, not a translation of the interface,
but every call site outside the Safe passed no locale to toLocaleString(),
so the format followed the browser locale: an English UI rendered
6/12/26, 2:30 PM instead of 12.06.2026, 14:30.

Adds formatSwissDate() and formatSwissDateTime() to util/utils.ts, next to
the existing formatChf()/formatCurrency() helpers, and routes the compliance
and RealUnit call sites through them. Explicit field options rather than
dateStyle: 'short', which would shorten the year to two digits.

The compliance formatDate()/formatDateTime() helpers now delegate to the
shared ones, which covers their callers across the compliance components
without touching each import.

formatDateTimeShort() keeps its own option set: the two-digit year is the
point of that variant. It only gains the fixed locale.

* fix(format): keep compliance seconds and unify the remaining date sites

Review follow-up on the shared Swiss date helpers.

A bare toLocaleString() emits seconds, so routing the compliance
formatDateTime() through a minute-precision helper silently dropped them
across roughly 27 call sites - note lists, call queue comments and issue
tables, where events can share a minute and the timestamp is what orders
them. Adds formatSwissDateTimeWithSeconds() and uses it for the compliance
views, which now keep exactly the precision they had.

Folds the Safe formatTimestamp() into formatSwissDateTime(). It carried an
inline copy of the same option set since the Safe fix; the output is
identical, so the Safe baselines stay valid.

Eight compliance sites passed de-CH but no field options and rendered
12.6.2026 next to the padded 12.06.2026 - among them a KYC step's created
date shown both ways on adjacent screens. They now use formatSwissDate().

Two more sites followed the UI language on screens this change already
pins: the support dashboard bucket labels and the recommendation expiry
date on the account screen.

Adds unit tests for the three helpers, built from local date components so
they hold in any timezone the suite runs in.

* fix(format): preserve timestamp precision and fold the last duplicates

Second review round.

Precision is now preserved everywhere rather than only in compliance. The
ten RealUnit and account sites also emitted seconds through a bare
toLocaleString() and had silently dropped to minute precision; they use
formatSwissDateTimeWithSeconds() again. The rule across the change is that
notation is fixed and precision is left exactly as each site had it, so the
Safe keeps the minute precision it chose explicitly.

The live financial dashboard passed de-CH without field options and showed
an unpadded timestamp directly above the padded log table below it.

Five compliance screens carried their own formatDate() with an option set
identical to the shared helpers; they delegate now and keep their '-'
guards for missing values.

trendLabel() took no locale for the month branch, which is the branch the
default 365-day period actually renders. A month name is a word rather than
notation, so it follows the interface language, as the other month labels
in the app already do; the numeric day label stays Swiss. The locale is
passed in explicitly, so no caller falls back to the browser.

* fix(support): let the trend labels follow a language switch

Third review round.

loadStats() reads the locale but did not list it as a dependency, and the
hook functions around it are memoized on a stable identity, so the callback
captured the language active when the screen mounted. Switching the
interface language left the month labels in the previous language, and a
period change did not clear it either - which defeats the point of passing
the locale in at all. Neither gate catches this: the lint config does not
load the React hooks plugin, and the build runs with CI=false, so the
warning never fails anything.

Also widens a comment and a test name that still said "compliance": the
seconds variant serves every site that emitted seconds before, which since
the last round includes the RealUnit and account screens.

The previous commit message justified the live-dashboard change with a log
table sitting below that card. That table is rendered nowhere in the app -
the component has no importers at all - so the justification was wrong,
though the change itself is right and matches the rule the rest of the
change follows. The PR description carries the corrected wording.

* fix(format): convert the reachable transaction screens and guard the stats race

Fourth review round.

The account list links one click into the transaction detail. Converting
only the list left the same timestamp reading two ways a navigation apart,
where before this change both sides followed the browser and at least
agreed. The three transaction detail sites and the support ticket list are
converted too; all four preserve their field set, seconds included. The
payment screens and the chat stay out of scope: nothing this change
converts navigates into them.

Passing the locale into loadStats() also made the language a second trigger
for a fetch that had no request guard, so a slow earlier response could
land last and put the previous language's labels back - the very bug the
locale was threaded in to fix. Only the newest request may write now. The
period selector could already race the same way; it is covered by the same
guard.

* fix(format): drop a site that was never locale-dependent, localize labels at render

Fifth review round.

The price step timestamp is typed Date but arrives as a raw ISO string: the
API layer parses JSON without a reviver, so the old call landed on
String.prototype.toLocaleString() and returned the string unchanged - in
UTC and identical in every locale. Converting it moved the instant into the
viewer's zone, dropped the Z marker and dropped the milliseconds, which is
the opposite of what this change promises. It is reverted, with a comment
so the next reader does not repeat it. The other two sites on that screen
are real Date values and stay converted.

Loading the statistics per language turned a relabel into a refetch: the
chart blanked behind a spinner on every language switch, and a refetch that
failed replaced statistics that were already correct with an error page.
The buckets are stored with their stable keys now and localized in a memo
at render time, so a language switch only relabels. The request guard stays
for the period selector, which can still race itself, and the fallback
ticket fetch is no longer issued at all once its request is superseded.

* fix(support): keep the statistics fallback reachable, pin the chat clock

Sixth review round.

Storing the statistics DTO unchecked lost a behaviour the old code had by
accident: reading dto.trend threw on an empty or unparseable 200 body - the
api layer resolves those as undefined - and that throw is what dropped into
the ticket fallback. Without it the view spun forever, with no fallback, no
error and no way back short of a reload; a partial DTO was worse, because
the render-time memo then threw outside the promise chain. The response is
checked explicitly now.

The chat message time asked for hour12: false against the browser locale.
That is not the same as a 24-hour clock: on locales that default to h24 it
renders midnight as 24:30, so an en-US browser disagreed with every
converted site by an hour a day, one click from the converted ticket list.
It uses a fixed Swiss time now, with a test for the midnight case.

* fix(support): check the statistics response shape, not its truthiness

Seventh review round.

The guard added last round tested truthiness, which is weaker than the
behaviour it restored. A 200 body whose trend is truthy but not an array -
a serialized map, say - passed it, was stored, and then threw in the
render-time memo instead: outside the promise chain, so the ticket fallback
never ran and the router error screen replaced the dashboard. Reading
dto.trend on develop threw inside the promise and fell back correctly.
Array.isArray covers the undefined and null cases too.

The two tests added for the Swiss time helper did not actually pin it. The
plain 14:30 assertion passes with the old browser-locale implementation in
every language the app offers, and the midnight one only fails where the
runner's own locale resolves to a 24-hour clock - so it would pass on a
European machine and catch nothing. A third test asserts the fixed locale
directly; reverting the helper now fails two of the three.

* fix(test): make the spy-based time test failure-safe

Closing out round 8. Two findings from the last review pass, applied
directly rather than starting another round.

The spy on Date.prototype.toLocaleTimeString only restored on the
happy path; a failing assertion would have left it in place for
every later test in the run. It restores in a finally now.

The PR body's carve-out for the statistics-tab screenshot gave the
wrong reason. That shot does render a changed line - trendLabel's
month branch - it just reproduces its old output today, because the
default period selects month granularity and the app locale derives
from the browser language, so both resolve the same short month name
on any machine that took the existing baseline. Worded accurately
instead of "wrong tab".

* test(e2e): regenerate the one baseline this change can cleanly affect

Ran the local API stack per CONTRIBUTING.md and checked all six specs the
description lists as touched.

Three read `formatDate`/`formatDateTime`/`trendLabel` output through a
real backend and need named fixtures (a specific customer, tickets in
specific states) that no available seed script creates - compliance.spec.ts,
support-dashboard-overview.spec.ts and support-dashboard.spec.ts stay stale
for that reason, unrelated to this change.

Of the three that mock their feature data and only needed working admin
auth, six of their seven shots keep the app under this repo's own
5000-pixel tolerance - the date-string length change is real but small.
realunit-compliance's dossier baseline is unusable for a different reason:
independent of this change, it already reflects substantial unrelated UI
drift (a Checks column, layout and fields the current screen no longer has)
against the current app, so regenerating it would bury this change inside
a confusing, unrelated diff.

Only realunit-support-01-list crosses the tolerance on a clean diff -
verified pixel-for-pixel that only the Created/Last Msg columns changed;
every other cell is identical.
* Report the errors customers are shown

The error screen is the router error element, so it is the one place where the error that broke the render is still available. It read none of it: useRouteError was never called, the error object was discarded, and the failure existed nowhere but the customer browser console.

The screen now reads the error and reports it, and the chunk-reload guard moves to the same place. The guard previously listened on window error and unhandledrejection events, which a failed React.lazy import never reaches, because React hands it to the error boundary instead. So the recovery never ran for the case it was written for.

Also reports the deliberate navigations to /error?msg=..., which have no route and therefore surfaced as a bare 404 saying nothing about the actual failure.

The comment in public/_redirects claimed real files are matched before the SPA fallback. That holds only while they exist: a build asset that does not is answered with index.html and status 200, which the /static/* header rule then stamps with a year of immutable cache. Corrected to record what a request for an absent asset actually returns.

* Stop classifying a JSON response that arrived as HTML as a chunk failure

Chunk detection also matched the bare syntax wordings a browser produces while parsing HTML as a script. One of them, Unexpected token '<', is what JSON.parse reports when a response is HTML instead of JSON - an everyday failure whenever a gateway, WAF or login redirect answers an API call. That would have reloaded the page and discarded whatever the customer had typed, which is worse than the failure it was meant to recover from. Verified against the wording current browser engines produce.

The bundler wordings identify the real case on their own, so the generic ones are gone and a regression test holds the line. The MIME type refusal stays: a response cannot reach JSON.parse and be refused for its MIME type.

Also: an empty message no longer drops the whole report, since the endpoint requires one and the rejection was swallowed silently - the exact blind spot this reporting exists to close. The app now identifies itself, so reports stop arriving as an unknown client. And building the payload moved inside the guarded block, because the fallback to String(error) can throw on a hostile value.

* Drop the MIME type branch from chunk detection

The branch matched the wording a browser prints when it refuses a script for its MIME type, and the comment presented that as real coverage. The wording is a console diagnostic; nothing establishes that it ever reaches application code as an error message, and the case it was meant to cover is already carried by the branch next to it: a stale chunk answered with the app shell makes the script load without registering the chunk, and the bundler's loader turns that into a ChunkLoadError reading `Loading chunk N failed. (missing: <url>)`.

So the branch was an unverifiable claim guarding a case that is already handled. Removed, and the test that asserted the regex against its own assumed string is replaced by one built from the wording the bundler actually produces.

* Report the route the customer was on, and cover the widget entry point

The reported route came from window.location. That is right for the app, but the widget and library builds run on a memory router, where the browser URL belongs to the host page and never changes as the customer moves between screens. Every report from an embedded deployment would have carried the same wrong route - in the contexts that are hardest to debug from a support ticket. It now comes from the router.

The test was hiding this: it forced window.location to match the route before rendering. It now sets it to something else on purpose and asserts the reported route is the router's, which is exactly the divergence the embedded builds live with.

The widget build swaps index-widget.tsx in for index.tsx, so the window-level handling wired up in index.tsx never reached that bundle. Extracted into a shared function and called from both. The widget is embedded on third-party pages, which is where a chunk left stale by a deploy has the longest to sit in a cache.

Also: the chunk-message test asserted on an error that carried the ChunkLoadError name as well, so it passed on the name check and never pinned the message pattern - it now asserts the message alone. And the comment claiming the field limits are kept in sync with the endpoint now says plainly that nothing enforces that across repositories.

* Never reload a host page the app is only embedded in

The widget and the library run inside someone else's page, and window belongs to that page - the closed shadow root isolates DOM and CSS, not window. Recovering a stale chunk by reloading would therefore discard state that has nothing to do with us: the host's forms, their cart, their scroll position, over a deploy of ours. The previous commit made this worse by wiring the window listeners into the widget entry point, where the reload had the most reach.

Both embedded builds now mark themselves, and recovery there falls back to reporting without reloading. The customer still sees the error screen inside the widget, and we still learn about it - only the host page is left alone.

The library entry point also never installed the handling at all: it is imported straight into a consumer's React app and has no entry point of its own, which the comment listing the entry points had missed. It installs it now, reporting only.

Also asserts the reported route in the no-match case, which is where the router's own location is least obviously defined.

* Give the embedded customer a way out instead of a dead end

Suppressing the reload on a host page was right, but it left nothing in its place. The only thing the error screen offers is the support button, and the support screen is lazy-loaded like every other one - so it fails on the same chunk and puts the customer straight back where they were. Meanwhile the text told them to return to the previous page, which does not help either.

Embedded, a chunk failure now says to reload the page, in all three languages, and the support button is left out of that case: it would only send them in circles. Every other failure keeps the button, because reloading would not fix those.

The window listeners are also no longer installed when embedded. They are page-wide, and the page belongs to the host - the widget is a web component in their window, not an iframe - so on a host that ships its own bundler they would have caught that bundler's chunk failures and filed them as ours. Recovery is off there anyway, and the failures worth seeing come through the router regardless.

And installChunkErrorHandling, the wiring all three entry points depend on, had no test of its own: which event property carries the error was covered nowhere. It is now, for both channels.

* Drop the calls that mark themselves embedded and then do nothing

Both embedded entry points called installChunkErrorHandling after marking themselves embedded, which makes the call return immediately - in Main.lib.tsx the two sit on consecutive lines, and index-widget.tsx imports Main.widget, whose module body runs first. So neither call could ever install anything, while the comments beside them described it as active handling.

Removed, with the reason stated where the reader will be looking: the embedded builds do not install page-wide listeners because the page is the host's. The guard inside the function stays, so a future entry point that calls it without checking still gets the right behaviour.
@TaprootFreak
TaprootFreak merged commit fb25299 into main Aug 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants