Skip to content

Releases: jbourdin/expandedDecks

v1.12.32

31 May 19:24
16c26f2

Choose a tag to compare

[1.12.32] — 2026-05-31

Patch release: the incremental TCGdex sync (F6.13) becomes multi-locale. TCGdex publishes a set in English first and adds French (and other) translations over the following days, so production previously never picked up the late-arriving translations. The sync now fetches the locale-independent data plus every configured locale, filling translation gaps as they become available, and captures a per-card updated baseline for future set-level freshness diffing.

Features

  • TCGdex multi-locale sync — gap-fill + force update (F6.17) — The SyncMode enum's three cases (Insert/Update/Full) are replaced by two: Sync walks the whole catalogue, inserts anything missing, and for each existing card fetches only the locales it still lacks — a card whose every configured locale is already populated is skipped with no HTTP call; ForceUpdate targets a single set and re-fetches every card across every configured locale unconditionally. Per-card fetching is now locale-aware: a base-locale (English) call carries the locale-independent fields while each additional locale is merged into the JSON columns by a new TcgdexCardHydrator::mergeLocaleFields() (abilities/attacks matched by list position, other locales preserved). 404 handling is split by locale — a base-locale 404 means the card genuinely doesn't exist (stop), while a translation-locale 404 means that translation isn't published yet (skip quietly, refill on a later sync). The locale list is the container parameter app.tcgdex.locales (['en', 'fr'], first entry is the base) so adding a locale is a one-line config change. A new nullable tcgdex_updated_at column on tcgdex_card (migration Version20260531120000, no backfill) captures the API's per-card updated timestamp on every touch — not yet a skip-decision input (locale completeness remains the active freshness signal), but a baseline so set-level diffing can switch to it once TCGdex exposes a set-level timestamp. The admin technical dashboard's two buttons become one Sync button plus a Force update set-picker form (TcgdexForceUpdateFormType); the CLI (app:tcgdex:sync) and signed webhook are simplified to gap-fill. The former card-count change-detection heuristic is removed (it could not detect locale gaps), so each Sync now walks every existing set — per-card calls are still avoided by the completeness skip and TcgdexApiThrottle rate-limits set discovery. (#655)

v1.12.31

30 May 21:25
4b8682c

Choose a tag to compare

[1.12.31] — 2026-05-30

Patch release: drag-and-drop reordering no longer pretends an archetype or deck variant was updated. A position-only change now leaves the freshness timestamps untouched, so the catalog "Updated on" caption, the "sort by updated" order, the sitemap <lastmod>, and the JSON-LD dateModified stop reporting false content changes whenever items are merely re-ranked.

Bug Fixes

  • Skip freshness timestamps on position-only reorder — Reordering archetypes (F18.11/F18.12) or deck variants (F18.19) only moves the position field, but the #[ORM\PreUpdate] hooks re-stamped updated_at (and, for published archetypes, last_published_at via PublishableTimestampsTrait) as if the content had changed — and a variant reorder additionally bumped the parent archetype's last_published_at through ArchetypeFreshnessListener. Three stamping paths were involved, so the fix is applied at each: a new StructuralChangeTrait lets Archetype and Deck early-return from their PreUpdate hook when position is the sole changed field, and the listener moved its update collection from postUpdate to preUpdate (where Doctrine reliably exposes the change-set) to apply the same guard — leaving postPersist (a genuinely new variant) and the postFlush bulk writer intact. A change that touches a real field alongside position still bumps as before. Coverage: a unit StructuralChangeTest (position-only inert, content and position+content both bump, for both entities) plus functional ArchetypeFreshnessListenerTest cases proving a variant reorder bumps neither the deck nor the parent archetype while a variant content edit still bumps both. (#652)

v1.12.30

29 May 09:50
906db90

Choose a tag to compare

Patch release: editor-defined Open Graph image and description controls land on decks, archetype translations, archetype variants, and the Banned/Staple Cards listing pages. Extends F18.28 (the existing site-wide OG meta tags) with editorial overrides via a single OgMetaResolver service that centralises the variant-and-locale fallback chain, and reuses the existing ImageUrlField drag-and-drop React component across the three new admin form surfaces.

Features

  • Editor-defined OG image and description on decks, archetypes, variants (F18.30) — Decks gain non-translatable og_image (varchar 255, URL regex matching Page.ogImage) and og_description (TEXT), nullable. ArchetypeTranslation gains the same two columns per-locale, kept independent from metaDescription so editors can tune length specifically for social cards. A new stateless App\Service\Seo\OgMetaResolver centralises the fallback chain: for a deck the owner's own values win; for an archetype variant (owner === null + archetype set, see Deck::isArchetypeVariant()) the resolver crosses into the parent archetype's locale-scoped values; og_image ultimately falls back to Deck.currentVersion.mosaicImageUrl to preserve the existing F18.28 behaviour. Archetype og_description gracefully falls back to archetype.localizedMetaDescription(locale) when no OG-specific copy is supplied, so existing meta descriptions keep working unchanged. The DeckShowController and ArchetypeDetailController inject the resolver and pass the resolved values into their templates; _partials/opengraph.html.twig (which already null-guards both fields) needs no template-system change. Admin form fields render with the existing ImageUrlField Mantine drag-and-drop component via the admin/_image_url_field.html.twig macro — the React mount loop is extracted to assets/shared/mount-image-url-field.tsx and reused from page-form, deck-form, and archetype-form Webpack entries (page-form's previously-inline copy is replaced by the same call). Coverage: 13 unit tests in OgMetaResolverTest exercising variant fallback, archetype meta-description fallback, and missing-translation paths. (#650)

  • Editor-defined OG image and description on Banned & Staple Cards pages (F18.31) — Extends F18.30 to the two CMS listing pages, which already pull their intro content from a Page entity via ListingIntroPage::BANNED_CARDS_SLUG / STAPLE_CARDS_SLUG. PageTranslation gains per-locale og_image (varchar 255, URL regex) + og_description (TEXT); the existing parent-level Page.ogImage is kept as a channel-wide default and overridden per-locale only when set. Render precedence in OgMetaResolver::resolveForPage(): PageTranslation.ogImagePage.ogImage → no og:image tag; PageTranslation.ogDescription → no og:description tag. og:title deliberately keeps the existing 'app.banned_card.public.title'|trans (and the staple equivalent) rather than reusing PageTranslation.title, because PageTranslationFormType hides the title field when is_listing_intro is true and editors don't author it for listing intros. BannedCardController and StapleCardController inject the resolver and pass the resolved values into their templates; templates/banned_card/list.html.twig is wired to emit the new fields; templates/staple_card/list.html.twig gains a full {% block opengraph %} block (it had none). AdminPageController::duplicate() also copies the new per-locale fields when cloning a page so duplicates carry their social metadata. Admin labels live under app.cms.form.og_*_localized to distinguish from the parent-level app.cms.form.og_image. Coverage: two functional tests on BannedCardController assert og:image and og:description render from editor input and degrade gracefully when blank. (#650)

v1.12.29

28 May 21:52
dd6abf3

Choose a tag to compare

Patch release: basic-energy fallback images are now homogeneous across all 9 colors, sourced from a single CDN (TCGdex sm1/164–172) and a single artwork era (Sun & Moon base, 2017). Previously the 8 non-Fairy colors pulled MEE artwork from assets.pokemon.com while Fairy alone came from images.pokemontcg.io/sm1/172_hires.png, and a separate set of persisted printings under PTCG-Live energy-only set codes carried legacy SVE pokemon.com art or 404-returning TCGdex assets.tcgdex.net/en/me/mee/* URLs. A two-pass data migration heals every affected card_printing row on deploy.

Bug Fixes

  • Homogenize basic-energy fallback images on TCGdex sm1/164–172CardEnricher::BASIC_ENERGY_IMAGES now points all 9 basic-energy fallback URLs (× 7 localized name variants, 62 entries total) at https://assets.tcgdex.net/en/sm/sm1/<164…172>/high.webp. sm1 is the only TCGdex-deployed set that contains every basic-energy type including Fairy (retired post-SWSH), and empirical CDN probes on 2026-05-28 confirm 9/9 colors are served — while assets.tcgdex.net/en/me/mee/* and assets.tcgdex.net/en/sv/sve/* still return 404 (TCGdex source data for both sets has existed since tcgdex/cards-database#1125 but the artwork has not been deployed). The visible cost is artwork-era regression: 8 of 9 colors revert from 2025 MEE/SVE "Basic" banner art to 2017 Sun & Moon art, in exchange for a single CDN, single era, and no images.pokemontcg.io runtime dependency (that source is deprecated upstream in favour of paid Scrydex). A new data migration Version20260528230443 runs two passes to heal already-enriched decks: pass 1 matches the 9 exact pre-PR fallback URLs (synthetic tcgdex_id LIKE 'energy-%' printings); pass 2 JOINs card_printing on card_identity and remaps every printing whose set_code is one of the PTCG-Live energy-only codes (MEE/SVE/SME/XYE/BWE — the same list as CardEnricher::ENERGY_SET_CODES) and whose canonical name is a basic-energy name. That second pass is required because some persisted rows had tcgdex_id='mee-001' but image_url=…/SVE/SVE_EN_1.png (the row was originally enriched via the ENERGY_SET_IMAGES['SVE|N'] path and later canonicalized to the MEE TCGdex ID without rewriting the image URL). down() reverses only the narrow URL-match pass — the broader sweep is intentionally one-way since original URLs varied per row and weren't recorded. DeckListParser::DEFAULT_BASIC_ENERGY_PRINTINGS (minified deck-list export, printed labels, mosaic, Cardmarket export) is deliberately untouched — its setCode is rendered on physical labels as MEE 1/MEE 2/etc. and is a separate user-visible identifier whose swap requires its own decision. ENERGY_SET_IMAGES (the exact SVE|N / MEE|N lookup map) is also unchanged — TCGdex still doesn't host that artwork. data/basic_energies.json adds the verified TCGdex URLs to the 9 canonical sm1 entries (additive only; defaultForMinified flags continue to mirror DEFAULT_BASIC_ENERGY_PRINTINGS). When TCGdex finally deploys MEE artwork (re-probe assets.tcgdex.net/en/me/mee/001/high.webp), the 8 non-Fairy fallbacks can move to modern art in a follow-up while Fairy stays on sm1. (#648)

v1.12.28

27 May 19:01
d7ca645

Choose a tag to compare

[1.12.28] — 2026-05-27

Patch release: damage-aware card identity signatures so cross-era Pokemon reprints that share an attack name but re-tune its damage no longer collapse into a single CardIdentity (e.g. Sandile/Bite/20 dmg in bw2-60 vs Sandile/Bite/30 dmg in swsh12-111). Ships with an admin button at /admin/technical that rebuilds every Pokemon identity's signature from the local TCGdex mirror and splits the ~50 mis-merged identities into distinct rows. Also clears the upstream CVE wave published 2026-05-26/27 on the Symfony 8.0.x family, Twig 3, and the npm tmp package.

Bug Fixes

  • Attack damage now disambiguates card identity signatures (F6.10 follow-up) — Cross-era reprints share attack names but re-tune damage values, and the previous name-only attack_signature collapsed mechanically-distinct cards into one CardIdentity. Local prod-mirror analysis surfaced 50 such mis-merged groups versus only 10 genuine type-only variants. CardIdentityResolver::computeAttackSignature now folds each attack's damage into the sorted signature as name|damage (| is the separator because no TCGdex attack name contains it — verified against the local mirror — while : is unsafe due to "C.O.D.E.: Protect" in sv08-069). The TcgdexCard DTO gains an attackDamages parallel list<int|string|null> populated from both the HTTP parser (parseCardData) and the local-mirror hydrator (buildDtoFromEntity), with a matching TcgdexCard::getAttackDamagesEn() entity helper whose skip rule stays in lockstep with getAttackNamesEn() so the two arrays remain index-aligned. A new admin card at /admin/technical ("Card identity signatures") drives the migration: a one-shot service walks every Pokemon CardIdentity, recomputes its signature from the local tcgdex_card mirror, updates in place when all printings agree, and splits divergent identities into find-or-created clones (primary group keeps the original row, picked by largest count with lowest CardPrinting.id tie-break). DeckCard rows reference CardPrinting (not CardIdentity), so re-pointed printings carry their decks across automatically; a one-shot DB check confirmed no current StapleCard or BannedCard would split under the new rule. Coverage: four unit tests for the rebuilder (single-group update, multi-group clone-when-no-target, multi-group reuse-existing-target, mixed-printings-missing-tcgdex-data), an extra clone-cache test for the unflushed-DB race within a single transaction, five entity tests on getAttackDamagesEn (parallel-with-names skip rule, string damage like "30+", missing/unsupported damage values, empty attacks), two functional tests covering the new admin endpoint (CSRF rejection + happy path), and updated assertions across the resolver test suite. (#646)

Infrastructure

  • Bump Symfony 8.0.x + Twig 3.27 + npm tmp to clear new CVE wave — Eight upstream advisories published 2026-05-26/27 broke Security Audit on every open PR. Composer side: symfony/http-foundation < 8.0.13 (CVE-2026-48736, SSRF bypass via IPv6 transition forms — 6to4 / NAT64 / Teredo / IPv4-compatible — in NoPrivateNetworkHttpClient), symfony/routing < 8.0.13 (CVE-2026-48784, UrlGenerator dot-segment encoding collapse under RFC 3986 normalization), symfony/security-http < 8.0.13 (CVE-2026-48489, firewall bypass via failure_forward subrequest granting unauthenticated access to access_control-protected GET routes), twig/twig < 3.27.0 (five sandbox bypasses: CVE-2026-48808 via the column filter, CVE-2026-48807 via Traversable in join/replace and in/not in, CVE-2026-48806 via dynamic mapping keys, CVE-2026-48805 via deprecated internal wrappers in core.php, CVE-2026-46636 when sandbox state changes between renders). Full Symfony 8.0.x family resolved to 8.0.13 via composer update "symfony/*" twig/twig --with-dependencies. npm side: tmp < 0.2.6 (GHSA-ph9p-34f9-6g65, path traversal via unsanitized prefix/postfix enabling directory escape) cleared with npm audit fix. (#646)

  • One-shot SQL script to backfill Archetype.last_published_at from variant decks onlyscripts/backfill_archetype_last_published_at.sql mirrors the live ArchetypeFreshnessListener rule (owner_id IS NULL) for ops use against prod if any archetype freshness timestamp was bumped by a player-owned deck under an earlier listener version. Not a Doctrine migration: kept as plain SQL because it doesn't change schema and only needs to run once. Uses COALESCE with a.first_published_at as the no-variants fallback (not GREATEST with the existing last_published_at value) so the script can correct rows that were wrongly bumped, rather than treating those wrong values as a floor. (#646)


v1.12.27

26 May 23:34
435b8f5

Choose a tag to compare

[1.12.27] — 2026-05-27

Patch release: fixes the Dialga GX duplicate-identity bug surfaced in production where mechanically-identical Pokemon printed with different elemental types (Dialga GX Metal vs Dragon, same name/HP/abilities/attacks) collapsed into a single CardIdentity row, plus picks up symfony/polyfill-intl-idn 1.38.1 to close CVE-2026-46644 which was published the day before this release.

Bug Fixes

  • Pokemon type now disambiguates card identities (F6.10 follow-up)CardIdentity gains a new pokemonType column (sorted comma-joined elemental types, e.g. "Metal", "Dragon", "Fire,Water"; empty string for Trainer/Energy as the sentinel matching the existing abilitySignature='' / hp=0 convention) and the unique index widens from (name, category, hp, ability_signature, attack_signature) to include it. The TCGdex data['types'] payload now flows end-to-end: TcgdexApiClient::parseCardData() extracts the types array, TcgdexCard DTO carries it, TcgdexApiClient::buildDtoFromEntity() forwards it from the local mirror, and CardIdentityResolver::computePokemonTypeSignature() produces the sorted signature consumed by findOrCreateIdentity() and expandPrintings(). The data migration Version20260526230542 walks every Pokemon identity via card_printing.tcgdex_idtcgdex_card.types (joining on the always-populated string identifier, not the often-NULL tcgdex_card_id FK), picks the largest type group as the keeper, clones the identity row for each other type group, and repoints those printings to the clone via a single bulk UPDATE … WHERE id IN (…). Printings whose mirror is missing get the empty-sentinel fallback and self-heal through future enrichment. Three new functional tests in DialgaGxCardIdentityTest exercise the real Doctrine layer (column, unique constraint, sorted signature) against MySQL: Metal vs Dragon split into distinct identities; same-type printings reuse one identity; dual-type signatures are sort-stable regardless of TCGdex JSON order. Four new parser tests cover the new types extraction (single, dual, missing-for-Trainer, local-mirror passthrough). Three new unit tests on computePokemonTypeSignature round out the helper's contract. (#644)

Infrastructure

  • Bump symfony/polyfill-intl-idn to 1.38.1 to clear CVE-2026-46644 — the advisory (insecure equivalence on xn-- labels whose Punycode payload decodes to ASCII-only) was published 2026-05-26 against >=1.17.1,<1.38.1, breaking composer audit across every open PR. The dep update pulled along its sibling Symfony polyfills (polyfill-intl-grapheme, polyfill-mbstring, polyfill-php83 to 1.38.1) plus minor patches on doctrine/orm 3.6.6 → 3.6.7, phpstan/phpstan 2.1.55 → 2.1.56, and phpunit/phpunit 13.1.11 → 13.1.12. Bundled into the same PR as the card-identity fix rather than spun off into a separate chore/deps-bundle because the Security Audit job blocked CI on every branch until the polyfill landed somewhere. (#644)

v1.12.26

26 May 06:39
fdca76c

Choose a tag to compare

Patch release: weekly Dependabot sweep, bundled into one PR with the inline lint fix needed to keep Frontend Quality green. 21 npm minor/patch updates + 2 composer minor/patch updates land together; three React fetch-and-setState call sites get inline react-hooks/set-state-in-effect disable comments to match the tightened rule shipped in eslint-plugin-react-hooks 7.1.1. The Mantine 8 → 9 majors, the @eslint/js 9 → 10 (which requires eslint 10), and webpack-cli 6 → 7 are deliberately deferred so each major can be reviewed in isolation.

Infrastructure

  • Bundle the weekly Dependabot sweep into one PR + silence the tightened react-hooks/set-state-in-effect rule — collapses the two open grouped Dependabot PRs (#640 npm with 21 updates, #637 composer with 2 updates) into one bundle: Composer side picks up minor/patch bumps that composer audit reports as clean; npm side bumps Babel 7.17/7.16/7.28 → 7.29.7 across core/preset-env/preset-react, @types/react 19.2.14 → 19.2.15, core-js 3.38 → 3.49, eslint-plugin-react-hooks 7.0.1 → 7.1.1 (the rule-tightening one), globals 17.4 → 17.6, react/react-dom 19.2.4 → 19.2.6, sass 1.99 → 1.100, stylelint 17.4 → 17.12, typescript-eslint 8.0 → 8.60, vitest 4.1.5 → 4.1.7, webpack 5.74 → 5.107. npm audit reports 0 vulnerabilities. The 7.1.1 bump of eslint-plugin-react-hooks tightened react-hooks/set-state-in-effect to flag setState calls inside useEffect bodies — including the canonical fetch+setState data-loading pattern that the codebase already exempts in ArchetypeVariantSelector.tsx. Three call sites tripped the new check and now carry // eslint-disable-next-line react-hooks/set-state-in-effect -- <justification> comments matching the existing convention: ArchetypeSelect.tsx:76 (typeahead fetch driven by debounced search), DeckVersionCompare.tsx:130 (diff fetch keyed on (fromVersion, toVersion)), NavbarSearch.tsx:75 (synchronous setGroups([]) clear when query drops below the 2-char threshold so stale results don't stay visible). Deferred from this bundle, queued individually: #607 (@eslint/js 9 → 10) — needs paired eslint 9 → 10 bump because @eslint/js v10 demands eslint v10 peer, ERESOLVE-fails on its own; #605 / #606 (@mantine/hooks and @mantine/core 8 → 9) — major API changes worth reviewing standalone; #520 (webpack-cli 6 → 7) — major plus a stale Security Audit failure that needs its own diagnosis. (#641)

v1.12.25

25 May 12:45
65e8961

Choose a tag to compare

Patch release: the durable fix for the IOPS/disk climb that 1.12.24's rel="nofollow" only deflected at the surface. Anonymous read-only requests no longer allocate a session row: LocaleListener stops writing _locale to the session on every request, and base.html.twig / _hero.html.twig gate every app.user and app.flashes access on cookie presence so Symfony's SessionTokenStorage is never consulted for cookieless visitors. After deploy, the sessions table should stop growing for crawler/bot traffic and the public catalog responses won't carry a Set-Cookie of the session cookie — which unblocks CDN caching on those URLs (queued for a later PR).

Performance

  • Anonymous read-only requests no longer allocate a session (#634) — the same root cause that drove the post-1.12.22 production IOPS / disk climb: every anonymous GET was opening a session row. Two changes close that off. (1) LocaleListener::__invoke() now refactors applyLocale() into a pure setLocale() that touches Request::setLocale() + LocaleSwitcher but never the session bag — the listener was previously calling getSession()->set('_locale', $locale) on every request, including locale-prefixed URLs like /en/archetypes where the URL is already authoritative. The session is now only read (for the _locale fallback chain) when a session cookie or REMEMBERME cookie says the visitor has prior state; cookieless visitors take the Accept-Language branch without consulting Symfony's session storage at all. LocaleSwitchController and ProfileController (the explicit user-action paths) keep their own getSession()->set(...) calls — by the time those fire the session cookie is already present. (2) base.html.twig and home/blocks/_hero.html.twig gate every app.user and app.flashes access on the same cookie-presence check via a local current_user = has_session_cookie ? app.user : null set at the top of the navbar block. Reading app.user consults Symfony's SessionTokenStorage even with lazy: true on the firewall, which was the second-largest session-starter after LocaleListener. The cookie-name check is dynamic — Session::getName() server-side, app.session.name in Twig — so it works against the production PHPSESSID cookie and the test env's MOCKSESSID (mock file session factory) without hard-coding either. LocaleListenerTest gains a testAnonymousCookielessRequestStaysSessionFree regression guard that asserts Security::getUser() is never consulted on a cookieless request. Verified end-to-end: DELETE FROM sessions; curl six anonymous URLs; leaves sessions empty and no Set-Cookie of the session cookie on any response, which unblocks the next step — emitting Cache-Control: public, s-maxage=… on the anonymous public-page response path so a CDN can cache them. (#635)

v1.12.24

25 May 11:28
42eea6f

Choose a tag to compare

Patch release: a crawler-deflection follow-up to the F2.27 freshness UI. After 1.12.22/23 shipped, production showed a sharp DB IOPS and disk-space climb. Best-guess root cause: bot crawlers walking the combinatorial tag-filter space on /archetypes (with N tags, 2^N tag subsets × 2 modes × M sort options = exponentially many unique URLs), each anonymous request opening a session row. This release adds rel="nofollow" on every filter anchor in the archetype catalog so well-behaved crawlers stop traversing the permutation space. A broader follow-up — skipping session allocation entirely for anonymous read-only requests, and applying the same rel="nofollow" treatment to the deck catalog and event-list filter rows — is queued separately.

Bug Fixes

  • rel="nofollow" on archetype catalog filter buttons — bot crawlers walking every tag combination on /archetypes were creating one anonymous session per permutation (tag set × AND/OR mode × sort option = 2^N × 2 × M URLs), driving up DB IOPS and disk usage from session storage. The "All" reset, every tag toggle, the drafts toggle (admin only), and both AND/OR mode anchors in templates/archetype/list.html.twig now carry rel="nofollow" so well-behaved crawlers stop traversing the filter space. noopener was deliberately not added — it's a target="_blank" security shield (window.opener attack) and is a no-op on same-window internal links, so it would have added markup noise without value. Same treatment should follow on the deck catalog and event-list filter rows in a later PR. (#632)

v1.12.23

25 May 10:19
5da36e6

Choose a tag to compare

Patch release: hotfix-grade follow-up to 1.12.22's F2.27 freshness UI. Production users in non-UTC timezones (the project's home base, CEST) saw a one-day drift between the archetype catalog's "Updated on …" caption and the same archetype's variant tile on the detail page, plus a locale mix where the prefix was in the request locale but the date itself was in the browser locale. The fix moves the variant date formatting from the React client to the PHP controller so the entire freshness rendering pipeline shares one locale and one timezone — the same pipeline Twig's format_date('long') uses on the catalog list.

Bug Fixes

  • Archetype variant date no longer drifts a day on non-UTC browsers (F2.27 follow-up) — production users in CEST (and any non-UTC timezone) saw the archetype catalog show "Updated on May 23" while the same archetype's selected variant tile showed "Updated on 24 mai". Two root causes: the React variant selector formatted the ISO timestamp with new Date(iso).toLocaleDateString(navigator.language, …) — which uses the browser's timezone (CEST shifts UTC midnight forward by 2h) and the browser's locale (always French on a French browser, regardless of the /en/… URL). The Twig list, by contrast, uses format_date('long') with the server timezone + request locale. Result: the same timestamp rendered as two different days and in two different languages on the same page. ArchetypeDetailController::buildVariantsData() now formats the variant's effective updated date server-side via IntlDateFormatter::create($locale, IntlDateFormatter::LONG, IntlDateFormatter::NONE) — the same locale + timezone Twig uses on the list — and passes the pre-formatted string as effectiveUpdatedAtLabel per variant. ArchetypeVariantSelector.tsx drops the new Date(...).toLocaleDateString(...) call and just renders the pre-formatted string after the labels.updatedOn prefix. (#630)