Releases: jbourdin/expandedDecks
v1.12.32
[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
SyncModeenum's three cases (Insert/Update/Full) are replaced by two:Syncwalks 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;ForceUpdatetargets 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 newTcgdexCardHydrator::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 parameterapp.tcgdex.locales(['en', 'fr'], first entry is the base) so adding a locale is a one-line config change. A new nullabletcgdex_updated_atcolumn ontcgdex_card(migrationVersion20260531120000, no backfill) captures the API's per-cardupdatedtimestamp 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 andTcgdexApiThrottlerate-limits set discovery. (#655)
v1.12.31
[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
positionfield, but the#[ORM\PreUpdate]hooks re-stampedupdated_at(and, for published archetypes,last_published_atviaPublishableTimestampsTrait) as if the content had changed — and a variant reorder additionally bumped the parent archetype'slast_published_atthroughArchetypeFreshnessListener. Three stamping paths were involved, so the fix is applied at each: a newStructuralChangeTraitletsArchetypeandDeckearly-return from theirPreUpdatehook whenpositionis the sole changed field, and the listener moved its update collection frompostUpdatetopreUpdate(where Doctrine reliably exposes the change-set) to apply the same guard — leavingpostPersist(a genuinely new variant) and thepostFlushbulk writer intact. A change that touches a real field alongsidepositionstill bumps as before. Coverage: a unitStructuralChangeTest(position-only inert, content and position+content both bump, for both entities) plus functionalArchetypeFreshnessListenerTestcases proving a variant reorder bumps neither the deck nor the parent archetype while a variant content edit still bumps both. (#652)
v1.12.30
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 matchingPage.ogImage) andog_description(TEXT), nullable.ArchetypeTranslationgains the same two columns per-locale, kept independent frommetaDescriptionso editors can tune length specifically for social cards. A new statelessApp\Service\Seo\OgMetaResolvercentralises the fallback chain: for a deck the owner's own values win; for an archetype variant (owner === null+archetypeset, seeDeck::isArchetypeVariant()) the resolver crosses into the parent archetype's locale-scoped values;og_imageultimately falls back toDeck.currentVersion.mosaicImageUrlto preserve the existing F18.28 behaviour. Archetypeog_descriptiongracefully falls back toarchetype.localizedMetaDescription(locale)when no OG-specific copy is supplied, so existing meta descriptions keep working unchanged. TheDeckShowControllerandArchetypeDetailControllerinject 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 existingImageUrlFieldMantine drag-and-drop component via theadmin/_image_url_field.html.twigmacro — the React mount loop is extracted toassets/shared/mount-image-url-field.tsxand reused frompage-form,deck-form, andarchetype-formWebpack entries (page-form's previously-inline copy is replaced by the same call). Coverage: 13 unit tests inOgMetaResolverTestexercising 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
Pageentity viaListingIntroPage::BANNED_CARDS_SLUG/STAPLE_CARDS_SLUG.PageTranslationgains per-localeog_image(varchar 255, URL regex) +og_description(TEXT); the existing parent-levelPage.ogImageis kept as a channel-wide default and overridden per-locale only when set. Render precedence inOgMetaResolver::resolveForPage():PageTranslation.ogImage→Page.ogImage→ noog:imagetag;PageTranslation.ogDescription→ noog:descriptiontag.og:titledeliberately keeps the existing'app.banned_card.public.title'|trans(and the staple equivalent) rather than reusingPageTranslation.title, becausePageTranslationFormTypehides the title field whenis_listing_introis true and editors don't author it for listing intros.BannedCardControllerandStapleCardControllerinject the resolver and pass the resolved values into their templates;templates/banned_card/list.html.twigis wired to emit the new fields;templates/staple_card/list.html.twiggains 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 underapp.cms.form.og_*_localizedto distinguish from the parent-levelapp.cms.form.og_image. Coverage: two functional tests onBannedCardControllerassertog:imageandog:descriptionrender from editor input and degrade gracefully when blank. (#650)
v1.12.29
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–172—CardEnricher::BASIC_ENERGY_IMAGESnow points all 9 basic-energy fallback URLs (× 7 localized name variants, 62 entries total) athttps://assets.tcgdex.net/en/sm/sm1/<164…172>/high.webp.sm1is 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 — whileassets.tcgdex.net/en/me/mee/*andassets.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 noimages.pokemontcg.ioruntime dependency (that source is deprecated upstream in favour of paid Scrydex). A new data migrationVersion20260528230443runs two passes to heal already-enriched decks: pass 1 matches the 9 exact pre-PR fallback URLs (synthetictcgdex_id LIKE 'energy-%'printings); pass 2 JOINscard_printingoncard_identityand remaps every printing whoseset_codeis one of the PTCG-Live energy-only codes (MEE/SVE/SME/XYE/BWE— the same list asCardEnricher::ENERGY_SET_CODES) and whose canonical name is a basic-energy name. That second pass is required because some persisted rows hadtcgdex_id='mee-001'butimage_url=…/SVE/SVE_EN_1.png(the row was originally enriched via theENERGY_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 — itssetCodeis rendered on physical labels asMEE 1/MEE 2/etc. and is a separate user-visible identifier whose swap requires its own decision.ENERGY_SET_IMAGES(the exactSVE|N/MEE|Nlookup map) is also unchanged — TCGdex still doesn't host that artwork.data/basic_energies.jsonadds the verified TCGdex URLs to the 9 canonicalsm1entries (additive only;defaultForMinifiedflags continue to mirrorDEFAULT_BASIC_ENERGY_PRINTINGS). When TCGdex finally deploys MEE artwork (re-probeassets.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 onsm1. (#648)
v1.12.28
[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_signaturecollapsed mechanically-distinct cards into oneCardIdentity. Local prod-mirror analysis surfaced 50 such mis-merged groups versus only 10 genuine type-only variants.CardIdentityResolver::computeAttackSignaturenow folds each attack's damage into the sorted signature asname|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" insv08-069). TheTcgdexCardDTO gains anattackDamagesparallellist<int|string|null>populated from both the HTTP parser (parseCardData) and the local-mirror hydrator (buildDtoFromEntity), with a matchingTcgdexCard::getAttackDamagesEn()entity helper whose skip rule stays in lockstep withgetAttackNamesEn()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 PokemonCardIdentity, recomputes its signature from the localtcgdex_cardmirror, 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 lowestCardPrinting.idtie-break).DeckCardrows referenceCardPrinting(notCardIdentity), so re-pointed printings carry their decks across automatically; a one-shot DB check confirmed no currentStapleCardorBannedCardwould 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 ongetAttackDamagesEn(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
tmpto 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 — inNoPrivateNetworkHttpClient),symfony/routing < 8.0.13(CVE-2026-48784,UrlGeneratordot-segment encoding collapse under RFC 3986 normalization),symfony/security-http < 8.0.13(CVE-2026-48489, firewall bypass viafailure_forwardsubrequest granting unauthenticated access toaccess_control-protected GET routes),twig/twig < 3.27.0(five sandbox bypasses: CVE-2026-48808 via thecolumnfilter, CVE-2026-48807 viaTraversableinjoin/replaceandin/not in, CVE-2026-48806 via dynamic mapping keys, CVE-2026-48805 via deprecated internal wrappers incore.php, CVE-2026-46636 when sandbox state changes between renders). Full Symfony 8.0.x family resolved to 8.0.13 viacomposer 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 withnpm audit fix. (#646) -
One-shot SQL script to backfill
Archetype.last_published_atfrom variant decks only —scripts/backfill_archetype_last_published_at.sqlmirrors the liveArchetypeFreshnessListenerrule (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. UsesCOALESCEwitha.first_published_atas the no-variants fallback (notGREATESTwith the existinglast_published_atvalue) so the script can correct rows that were wrongly bumped, rather than treating those wrong values as a floor. (#646)
v1.12.27
[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) —
CardIdentitygains a newpokemonTypecolumn (sorted comma-joined elemental types, e.g."Metal","Dragon","Fire,Water"; empty string for Trainer/Energy as the sentinel matching the existingabilitySignature='' / hp=0convention) and the unique index widens from(name, category, hp, ability_signature, attack_signature)to include it. The TCGdexdata['types']payload now flows end-to-end:TcgdexApiClient::parseCardData()extracts the types array,TcgdexCardDTO carries it,TcgdexApiClient::buildDtoFromEntity()forwards it from the local mirror, andCardIdentityResolver::computePokemonTypeSignature()produces the sorted signature consumed byfindOrCreateIdentity()andexpandPrintings(). The data migrationVersion20260526230542walks every Pokemon identity viacard_printing.tcgdex_id⨝tcgdex_card.types(joining on the always-populated string identifier, not the often-NULLtcgdex_card_idFK), 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 bulkUPDATE … WHERE id IN (…). Printings whose mirror is missing get the empty-sentinel fallback and self-heal through future enrichment. Three new functional tests inDialgaGxCardIdentityTestexercise 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 oncomputePokemonTypeSignatureround out the helper's contract. (#644)
Infrastructure
- Bump
symfony/polyfill-intl-idnto 1.38.1 to clear CVE-2026-46644 — the advisory (insecure equivalence onxn--labels whose Punycode payload decodes to ASCII-only) was published 2026-05-26 against>=1.17.1,<1.38.1, breakingcomposer auditacross every open PR. The dep update pulled along its sibling Symfony polyfills (polyfill-intl-grapheme,polyfill-mbstring,polyfill-php83to 1.38.1) plus minor patches ondoctrine/orm3.6.6 → 3.6.7,phpstan/phpstan2.1.55 → 2.1.56, andphpunit/phpunit13.1.11 → 13.1.12. Bundled into the same PR as the card-identity fix rather than spun off into a separatechore/deps-bundlebecause the Security Audit job blocked CI on every branch until the polyfill landed somewhere. (#644)
v1.12.26
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-effectrule — 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 thatcomposer auditreports as clean; npm side bumps Babel 7.17/7.16/7.28 → 7.29.7 acrosscore/preset-env/preset-react,@types/react19.2.14 → 19.2.15,core-js3.38 → 3.49,eslint-plugin-react-hooks7.0.1 → 7.1.1 (the rule-tightening one),globals17.4 → 17.6,react/react-dom19.2.4 → 19.2.6,sass1.99 → 1.100,stylelint17.4 → 17.12,typescript-eslint8.0 → 8.60,vitest4.1.5 → 4.1.7,webpack5.74 → 5.107.npm auditreports 0 vulnerabilities. The 7.1.1 bump ofeslint-plugin-react-hookstightenedreact-hooks/set-state-in-effectto flag setState calls insideuseEffectbodies — including the canonical fetch+setState data-loading pattern that the codebase already exempts inArchetypeVariantSelector.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(synchronoussetGroups([])clear when query drops below the 2-char threshold so stale results don't stay visible). Deferred from this bundle, queued individually: #607 (@eslint/js9 → 10) — needs pairedeslint9 → 10 bump because@eslint/jsv10 demandseslintv10 peer, ERESOLVE-fails on its own; #605 / #606 (@mantine/hooksand@mantine/core8 → 9) — major API changes worth reviewing standalone; #520 (webpack-cli6 → 7) — major plus a stale Security Audit failure that needs its own diagnosis. (#641)
v1.12.25
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 refactorsapplyLocale()into a puresetLocale()that touchesRequest::setLocale()+LocaleSwitcherbut never the session bag — the listener was previously callinggetSession()->set('_locale', $locale)on every request, including locale-prefixed URLs like/en/archetypeswhere the URL is already authoritative. The session is now only read (for the_localefallback chain) when a session cookie orREMEMBERMEcookie says the visitor has prior state; cookieless visitors take the Accept-Language branch without consulting Symfony's session storage at all.LocaleSwitchControllerandProfileController(the explicit user-action paths) keep their owngetSession()->set(...)calls — by the time those fire the session cookie is already present. (2)base.html.twigandhome/blocks/_hero.html.twiggate everyapp.userandapp.flashesaccess on the same cookie-presence check via a localcurrent_user = has_session_cookie ? app.user : nullset at the top of the navbar block. Readingapp.userconsults Symfony'sSessionTokenStorageeven withlazy: trueon the firewall, which was the second-largest session-starter afterLocaleListener. The cookie-name check is dynamic —Session::getName()server-side,app.session.namein Twig — so it works against the productionPHPSESSIDcookie and the test env'sMOCKSESSID(mock file session factory) without hard-coding either.LocaleListenerTestgains atestAnonymousCookielessRequestStaysSessionFreeregression guard that assertsSecurity::getUser()is never consulted on a cookieless request. Verified end-to-end:DELETE FROM sessions; curl six anonymous URLs;leavessessionsempty and noSet-Cookieof the session cookie on any response, which unblocks the next step — emittingCache-Control: public, s-maxage=…on the anonymous public-page response path so a CDN can cache them. (#635)
v1.12.24
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/archetypeswere 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 intemplates/archetype/list.html.twignow carryrel="nofollow"so well-behaved crawlers stop traversing the filter space.noopenerwas deliberately not added — it's atarget="_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
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, usesformat_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 viaIntlDateFormatter::create($locale, IntlDateFormatter::LONG, IntlDateFormatter::NONE)— the same locale + timezone Twig uses on the list — and passes the pre-formatted string aseffectiveUpdatedAtLabelper variant.ArchetypeVariantSelector.tsxdrops thenew Date(...).toLocaleDateString(...)call and just renders the pre-formatted string after thelabels.updatedOnprefix. (#630)