Skip to content

Nuxt 4 SPA migration: Laravel becomes API-only - #898

Open
edwh wants to merge 365 commits into
developfrom
nuxt-client
Open

Nuxt 4 SPA migration: Laravel becomes API-only#898
edwh wants to merge 365 commits into
developfrom
nuxt-client

Conversation

@edwh

@edwh edwh commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

What's included

  • Nuxt 4 SPA: Vue 3, Pinia, bootstrap-vue-next (Bootstrap 5), @nuxtjs/i18n, Vitest, Playwright e2e. All pages ported: dashboard, groups (+ map/nearby/all/create/edit), events (+ create/view/devices/attendees), fixometer, profile (view + edit tabs), admin reference-data, user/all, networks, notifications, auth.

  • Auth: Sanctum bearer + dual guard auth:sanctum,api; an SSO bridge (/auth/bridge) minting a web session for Discourse SSO + MediaWiki silent login.

  • Infra: docker-compose split into Nuxt + API containers; CI four-job topology (build, build-client, e2e-client); client translations exported from lang/*.php.

  • Design parity with the live site (G6) — reviewed page-by-page against production and matched:

    • Login: brand .panel/.panel__orange cards with the offset drop-shadow (ported the legacy _panels.scss into the global layer — no component overrides).
    • Dashboard: "Welcome to Restarters" hero + doodles, two-column Your Groups / Upcoming events panel, repair photo, blue info panel, amber "Getting started" call-out, and the restored Add Data group→event picker.
    • Fixometer: the teal stat-card grid + the inline searchable POWERED/UNPOWERED repair-records table with filter collapsibles.
    • CSS consolidation: the dashed content divider promoted to a global .content-divider; brand cards global, only page-specific layout scoped.
    • Assets rationalised to WebP (icons stay SVG); the missing /images/* set populated.
  • API security review (verified against the pre-migration controllers) — fixed a critical device IDOR, an open redirect in the SSO bridge, an event-visibility regression (unmoderated-group events were public), and a password-reset token replay; each with a regression test. See docs/nuxt-migration/findings/security-review-api.md.

Code Quality Review

  • The pinned web-route surface (ApiOnlyRouteSurfaceTest) and per-endpoint OpenAPI response validation guard against surface/shape drift.
  • New endpoints follow v2 conventions (explicit validated field lists — no mass assignment; role/api_token excluded from $fillable; Eloquent bindings — no raw SQL).
  • The Nuxt client is jQuery-free; jQuery survives only in the MediaWiki skin (wiki.js) and the embeddable widget pages' CDN libs — both outside the Vue/Nuxt app.

Test Plan

  • CI (build phpunit, build-client vitest 882 + lint + build, e2e-client Playwright) green on the branch head.
  • New coverage for every restored/added endpoint and flow (APIv2PublicProfileTest, APIv2DeviceUpdateAuthTest, APIv2EventVisibilityTest, SsoBridgeTest, AuthEndpointsTest, DashboardAddData.spec, e2e for dashboard/device/event flows).

Future Improvements

  • Remaining non-API web surface (see docs/nuxt-migration/findings/): the SSO bridge is irreducibly a web route; the redirectors + catch-all could move to nginx; admin preview-deploy should move into the SPA + an /api/v2 endpoint (it currently can't authenticate the SPA user, which is a session-vs-token mismatch). The anonymous export/calendar feeds and embeddable stats widgets are kept for external consumers/partners.
  • Security hardening backlog documented in security-review-api.md (repair-directory read gate, dead v1 /api/groups/ dump, dead draft-image copy path, tus filename-collision + disk-fill sweep, throttle gaps). The public group directory + shareable-link exposure were reviewed and deliberately left as-is.

edwh and others added 22 commits July 16, 2026 09:57
SKIP_NPM_INSTALL leaves no Vite manifest, so the Blade homepage the shared
readiness check curled 500s (ViteManifestNotFoundException) — the same trap
class the Phase F audit flagged for the cutover. The wait-for-core command
gains a probe parameter; the build job points it at /api/v2/session.
The npm-less build job exposed two couplings: 13 legacy page-render tests
throw ViteManifestNotFoundException without a built manifest (they assert
content, not asset tags — withoutVite() in TestCase::setUp removes the
dependency; these tests die at Phase F anyway), and phpunit starting ~12
minutes earlier hit a cold Discourse whose 5s-timeout retries made every
integration-adjacent test crawl (82 tests in 45 min). The build job now
waits for the Discourse web app non-fatally before phpunit.

Local phpunit verification deferred until the e2e agent releases the shared
DB; CI verifies in isolation meanwhile.
notifications.js moves to global/js/misc (canonical, survives cutover) with
a temporary annotated copy at the old path — vite's define global->window
shim (for select2) mangles any import path containing the token 'global'
from resources/js, and that tree dies at F anyway. box-shadows moves into
global/css/helpers; _cards.scss gets a local copy of the legacy fixometer
variables instead of reaching into resources/sass. Legacy vite build
verified green (also surfaced + fixed stale container deps from the #887
merge: leaflet-control-geocoder).
StatsShare rewritten as a vanilla self-initializing module under
resources/global/js/widgets (own vite entry — the partner iframe shouldn't
load the general global bundle). Translations baked server-side into
data-t-* attributes by the Blade partial. Bug-for-bug compatible with the
Vue original by design (fontSize switch fallthrough; emHeightAscent no-op
highlight box) — both documented in-code as candidates for a deliberate
follow-up, not silent changes to a live partner embed. leaf.blade.php no
longer references resources/js/app.js.

Ported by the statsshare-port agent; build re-verified (9.15kB chunk).
…ockfile repair

Closes the gap the redirector work exposed: unconsented users (admin-created,
pre-2018 accounts) had no path to record consents once the Blade form became
a redirector. pages/user/consent.vue posts to /api/v2/auth/consent (reusing
the registration lang strings) and auth.global routes consent.given===false
users there — strict false check so a still-loading session never redirects.
812 vitest green.

Also: vite.server.allowedHosts fixes the container-hostname rejection that
silently stalled e2e iteration for hours, and the client lockfile is
regenerated with STANDARD npm resolution — --legacy-peer-deps (a root-repo
necessity) drops peer deps like @popperjs/core and produces npm-ci-
incompatible lockfiles for the clean-npm client (third drift incident,
root cause closed).
Ported event/device/admin/grouptags flows (~46 tests; grouptags keeps the
legacy 34-test permission matrix incl. the 4 pure-API checks). Specs
authored by the phase-cde-e2e agent; verification and fixes completed
after takeover.

Client bugs found by these flows, all fixed with unit coverage:
- No cold-boot session bootstrap: full page reloads rendered logged-out
  (plugins/session.ts, awaited pre-paint).
- Event-page permissions derived from the dashboard's capped your_groups:
  stale for new groups; now GET /api/v2/users/me/groups via the groups
  store.
- hide-footer is not a bootstrap-vue-next prop: every modal (10 across 8
  components) rendered a duplicate default footer; now no-footer.
- DeviceForm sent null for empty optionals; the endpoint's validation
  (2025, pre-branch) requires omission — legacy client provably omitted
  falsy keys (store/devices.js FormData truthiness guard).
- Forced device-list refreshes swapped the table for a spinner, unmounting
  the row being edited (photo upload closed the edit form); panel now
  stale-while-revalidates.
- Category suggestion only reacted to typing, not to datasets arriving
  after; split watchers (user edits may clear, data arrival only fills).
- Suggestion e2e is self-seeding (test DBs have no production item-type
  mappings; the legacy spec was CI-excluded for this).
- e2e URL matchers: endsWith broke on BaseAPI's locale param.
… green)

user/reset + user/recover (GET only now; the API owns submission),
user/register/{hash?} (invite_hash carried through), group/party
accept-invite (DB semantics unchanged, redirect + joined/invalid query
flags replace session flash), group/party invite/{code} (session-array flow
gone entirely — the SPA claim endpoint owns it). Party::shareable_link now
points at the frontend like Group's. AcceptUserInvites middleware is now
provably unreachable (removed at F proper).

Authored by the f-redirectors agent; verified after e2e completion (two
route-removed tests needed withExceptionHandling for their 405 assertions).
The accept-invite redirectors replaced Laravel session flashes with query
flags, which left groups.invite_confirmed unused in Laravel code and broke
testCheckTranslations. The checker now scans client/app and client/e2e so
SPA t() usage counts. The group and party view pages now toast on
?joined=1 / ?invite=invalid and strip the params, restoring the legacy
flash messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
The two delete tests inline the flow to assert on tag-delete-warning
mid-way, so the helper was never called.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
fetchForEvent and addDevice created a plain entry object, assigned it
into byEvent, then kept mutating the plain reference - those writes
bypass Vue's proxy, so subscribers were never notified. Whether the
event page's devices panel left its loading skeleton depended on the
fetch beating the panel's first render: the recurring spare-parts /
add-device e2e flake (deterministic 6-minute timeouts on slow CI).
Assign the fallback into state first and read the proxy back. New spec
pins the notification, not just the post-await value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
/group/map as its own route/tab: markers for every non-archived group
from the names index (clustered, leaflet.markercluster), visible rows
hydrated via GET /api/v2/groups/summary?ids= chunked at the API's
200-id cap - the split-fetch architecture from PR #887. Photon place
search (leaflet-control-geocoder), CARTO tiles, row<->marker hover
linking in GroupsTable, and pure geometry helpers split into a
composable for unit testing. Deviations from legacy documented in
GroupMap.vue (no initial user-area bounds - no v2 source; marker popup
links to /group/view instead of the modal). 53 new vitest cases plus a
groupmap e2e spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
task docker:test:playwright:client -- <file> silently ran the full
suite; the args now reach npx playwright test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
…cation

Double-run e2e gate met (57/57 twice, zero flaky). Checklist gains
verified F2 details: errors pages must always use header_plain (navbar
needs seven dying route names), set-lang/set-cookie/user-thumbnail have
no surviving consumers, preview-deploy is kept with a session-auth
DECISION flag, and the route trim uses an explicit keep-list plus an
api-excluded catch-all redirect to the SPA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
The Nuxt SPA in client/ is now the sole user-facing frontend. Laravel
serves /api/v2 plus a small pinned web surface (SSO bridge, emailed/shared
deep-link redirectors, anonymous exports + calendar feeds, the embeddable
stats widgets, admin preview-deploy) and a catch-all that 302-redirects any
other browser navigation to the SPA. tests/Feature/ApiOnlyRouteSurfaceTest
pins that surface.

Removed:
- routes/web.php trimmed to the API-only surface + catch-all (api/ excluded
  so unknown API paths 404 as JSON).
- ~200 Blade views (all but the retained widget/error/email set),
  resources/js, resources/sass.
- laravel/ui + Auth/{Login,Register,ResetPassword,ForgotPassword,
  ConfirmPassword}Controllers; EnsureAPIToken, VerifyUserConsent (web),
  AcceptUserInvites middleware (VerifyUserConsentApi, the API gate, stays).
- Jest + tests/Integration + legacy Playwright config; the CI
  build-legacy-frontend job; jest/vue2 deps from the root package.json.
- The dead legacy-prefix web redirects (FaultCat etc.) -> nginx 301 map.

Changed:
- Authenticate::redirectTo -> FRONTEND_URL/login (route('login') is gone).
- vite.config.js builds only the widget + wiki assets; no Vue. docker_run.sh
  startup npm section slimmed.
- CheckTranslations scans client/ and builds one in-memory haystack instead
  of a grep per key (was ~20 min + a process-isolation deadlock); resolves
  the 'en' locale explicitly. ~290 now-dead lang keys pruned across en/fr/
  fr-BE. Client-side i18n gaps fixed (kg/CO2 tab suffixes, km, network alt).

Regressions the cutover had introduced, now fixed (found by test triage):
- Network logo upload had no API/UI -> POST /api/v2/networks/{id}/logo +
  TusImageUpload on the network page.
- The "Add groups to a network" modal called an unimplemented endpoint ->
  POST /api/v2/networks/{id}/groups.
- Group delete stopped enforcing "can't delete a group with a device" ->
  archivev2 re-checks Group::canDelete().
- The "request reviews from attendees" feature was dropped -> POST
  /api/v2/events/{id}/request-review + a host-only button.
- The event page's "follow group" link pointed at a removed route -> calls
  the join API.
- The notifications page (profile tab target) was never built -> GET/POST
  /api/v2/users/me/notifications[/read] + a /notifications page.
- The image_upload feature flag (disable writes in preview/staging) was dead
  for the API path -> FixometerFile::uploadLocalFile now honours it.
- Event-photo uploads stopped notifying admins to moderate -> uploadImagev2
  re-fires EventImagesUploaded.
- The groups-table filter bar (name/location/country/tags) wasn't ported ->
  client GroupsTableFilters.vue + client-side filtering in GroupsTable.

Tests: the Blade-render Feature tests were superseded by the APIv2*Test
suite + client vitest/e2e. loginAsTestUser now uses a factory user +
actingAs. Dead-Blade tests were deleted where coverage moved; hybrid tests
stripped of dead-page assertions and repointed to the live API; security
tests ported to policy assertions / new trigger points. New API tests cover
every restored endpoint; new e2e specs (networks, event request-review /
follow-group, notifications) exercise the flows that previously had no
coverage. See docs/nuxt-migration/cutover-checklist.md for per-file
decisions and remaining follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
Full phpunit green (772), client vitest 877, e2e 62 passed. Plus 9 real
regressions restored during test triage, each with API+e2e coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LzRkdRa6w1P3ZXKH5pGDh5
Live-vs-local parity review (docs/nuxt-migration/findings/g6-visual-review.md)
against the running restarters.net site found several cutover regressions:

- Profile pages (/profile, /profile/{id}) always showed "profile couldn't be
  found": the client calls GET /api/v2/users/{id}, which didn't exist. Add
  UserController::getPublicProfilev2 + route, returning the documented PII-safe
  shape (name/avatar/role/location/groups/skills/bio, NOT email) - any logged-in
  user may view any profile, matching the always-public legacy Blade page.
- The /group/all tab rendered the raw key GROUPS.ALL_GROUPS: add the
  client-only key client.groups.all_tab (en/fr/fr-BE) and point GroupsTabsNav
  at it. Added a label-resolution test (the old test only checked hrefs, which
  is why the missing key slipped through).
- client/public/images/ was never created, so every /images/* fallback
  (placeholder avatar, the icon SVGs, onboarding photos) rendered as a broken
  image. Populate it.
- Bootstrap 4 spacing utilities (mr-/ml-/pl-/pr-) are dead under bootstrap-vue-
  next's Bootstrap 5, so nav and footer links ran together -> me-/ms-/ps-/pe-.

Asset rationalisation (per request): raster -> WebP (placeholder-avatar 8K->4K,
onboarding photos ~280K->~38K each, via GD); the UI icons stay SVG (vector CSS
backgrounds - the "unless a reason not to" exception); line-endings LF-
normalised; kebab-case names; references updated across the client.

Tests: tests/Feature/Users/APIv2PublicProfileTest, GroupsTabsNav label test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate

Careful review of the API surface (docs/nuxt-migration/findings/
security-review-api.md), fixing the confirmed exploitable findings:

- CRITICAL - device IDOR: PATCH /api/v2/devices/{id} checked permission on the
  body eventid but overwrote the device loaded by the URL id, with no check it
  belonged to that event. Device ids are sequential, so a host of event A could
  PATCH a stranger's device with eventid=A and reassign/overwrite it. Now also
  require edit permission on the device's current owning event (matching
  deleteDevicev2). Test: APIv2DeviceUpdateAuthTest.
- HIGH - open redirect: BridgeController::safeRedirect used a bare
  str_starts_with, so https://<frontend-origin>.attacker.com passed the
  allowlist - and it fires right after establishing a real login session.
  Match on an origin/path boundary. Test:
  SsoBridgeTest::testRedirectAllowlistBlocksSuffixBypass.
- HIGH - event visibility regression: GET /api/v2/events/{id}, /attendees and
  /devices had no gate, so anyone could read full venue / exact lat-long /
  online-meeting link / attendee names / device notes for events on unmoderated
  (spam-risk) groups. Restore the legacy userHasViewPartyPermission gate (404);
  events on approved groups stay fully public. Test: APIv2EventVisibilityTest.
- MEDIUM - password-reset token replay: resetPasswordv2 never rotated recovery/
  recovery_expires, so an intercepted/forwarded link stayed valid for 24h even
  after use. Rotate it on reset (single-use), matching updateMyPasswordv2. Test:
  AuthEndpointsTest::testResetPasswordTokenCannotBeReplayed.

Remaining findings (repair-directory read gate, dead v1 /api/groups/ table
dump, dead draft-image IDOR path, tus filename collision + disk-fill, missing
throttles, public group directory showing unapproved groups) are documented
for follow-up. The group-directory visibility question needs a product decision
and was left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…guard

The H2 event-visibility gate (getEventv2/attendeesv2/devicesv2) used
$request->user(), which only checks the default 'web' guard. The SPA
authenticates with a sanctum bearer token, so on these auth-middleware-free
routes the acting user came back null and the gate 404'd even an admin or host
- which broke the e2e device-moderation flow (setUpApprovedEvent could no
longer load /party/view/{id} to approve an event on a freshly-created,
still-unapproved group). CI #4829 build+build-client were green; e2e-client
caught this.

Resolve the acting user across guards (web -> sanctum -> api, non-throwing) via
a new optionalUser() helper, matching the controller's own getUser(). Events on
approved groups were always public regardless, so only the moderation path was
affected.

Test: APIv2EventVisibilityTest::testUnapprovedGroupEventVisibleToAdminViaBearerToken
uses a real bearer token (no actingAs) and 404s an anonymous caller - it fails
on the old $request->user() path and passes now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…page

The event-visibility gate (H2, commit 837d454) correctly hides events on
unapproved (unmoderated) groups from everyone but their host/coordinator/admin.
The follow-group e2e ("Following the hosting group from the event page joins
it") had a non-member (USERS.host) view an event on a still-unapproved group,
so with the gate the event page now 404s and the follow button never renders
(CI #4830 e2e-client: 1 failed, 62 passed).

Add an approveGroup() util (mirrors approveEvent, via GroupForm's
group-form-moderate select + PATCH /api/v2/groups/{id}) and approve the group
in that test - matching the realistic flow: you follow a group once it's been
moderated/made public. Test-only change; locally event.test.js is green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
// establish, regardless of what the default guard has been set to.
if (Auth::guard('web')->check()) {
// Already have a web session (e.g. second bridge hop); no ticket needed.
return redirect($redirect);
Auth::guard('web')->login($user);
$request->session()->regenerate();

return redirect($redirect);
Comment thread tests/Feature/Auth/DualGuardTest.php Fixed
Comment thread tests/Feature/Users/APIv2UserEventsTest.php Fixed
Comment thread client/app/stores/devices.js Fixed
The login/dashboard templates already reference .panel / .panel__orange /
.panel__blue, but those classes were never ported from the legacy
resources/sass/_panels.scss - so the signature brand card (white fill, thin
black border, hard 6px offset drop-shadow) and the amber/blue variants were
missing, leaving flat bordered boxes vs the live site.

Add client/app/assets/css/_panels.scss (ported verbatim, $brand-black -> $black)
+ the $brand-blue token, imported in restarters.scss alongside the other
component base layers. No component-scoped CSS: the existing template classes
now resolve. Fixes the login page's amber welcome panel + card shadows; the
same classes back the dashboard panels (D2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
edwh and others added 29 commits July 21, 2026 22:41
The highest-severity item in the parity audit, and a consent gap rather than a
styling one: "Cookie settings" was a NuxtLink to /about/cookie-policy, so a
visitor could accept everything or read an article - there was no way to
decline analytics. Consent was a single boolean in localStorage.

develop's gdpr-cookie-notice opens a modal listing each category with its own
switch and a Save button. /about/cookie-policy is a SEPARATE "statement" link
inside that modal, not the destination of the settings control - we had wired
the secondary link in place of the primary one.

Now: a settings dialog with Essential (listed, "Always on", no switch, because
the site cannot work without it), Performance, Analytics and Marketing
toggles, and a Save that persists the choice. The OK button keeps develop's
accept-all defaults - marketing off, everything else on.

Stored as develop's cookie, not localStorage, keeping its exact name
(gdprcookienotice), shape and key spellings - including "performace", which is
misspelled in develop. Renaming it would silently discard the stored
preference of every visitor who set one on the legacy site, and the retained
Blade widgets read that cookie: header.blade.php derives
window.restarters.analyticsCookieEnabled from it to gate Matomo and Sentry.
useCookieConsent now exposes analyticsEnabled for the same purpose here.

Strings taken verbatim from develop's gdpr-cookie-notice locale, translated
for fr/fr-BE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Controls that looked right and did the wrong thing - the class a screenshot
diff cannot catch, because the pixels match and the behaviour does not.

1. EventCard offered a live RSVP/cancel button on events that had already
   happened. GroupEventsScrollTableActions.vue gates the whole actions cell on
   `v-else-if="upcoming"`, and for an attendee shows static bold "You're
   going!" text - there is no un-RSVP from this table at all. Two tests
   asserted our version, including one I added earlier, so they pinned the
   divergence rather than develop's behaviour; both corrected.

2. The dashboard's nearby-groups "Join" button navigated to the group page
   instead of joining. Its comment said the join endpoint "lands with
   B2/B4-B5"; POST /api/v2/groups/{id}/members/me has existed since then and
   groupsStore.join() already wraps it. A button labelled Join that does not
   join.

3. Password reset ended on an invented in-page success alert with no route
   back to the sign-in form. UserController::reset() redirects to /login with
   the passwords.updated flash; login now shows that flash on arrival.

4. Nearby-group join and reset both needed their specs updating rather than
   worked around - the join spec asserted an href, which was the bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
1. Shareable-link invites sent logged-out visitors to /login.
   confirmCodeInvite redirects to /user/register with the
   auth.login_before_using_shareable_link banner - a shareable link normally
   goes to someone with no account yet, so the sign-in form was the wrong step
   first. The banner still offers the login route.

   The success message also reused groups.invite_confirmed, develop's text for
   the unrelated email-hash accept-invite flow, and named nothing. develop
   flashes groups/events.you_have_joined - "You have joined <linked name>".
   The claim endpoint now returns the group/event name so that message can be
   rendered. already_member stays as our own string: develop has none here,
   and inventing linked copy to match would repeat the mistake this audit was
   about.

2. The navbar showed the full wordmark at every width. navbar.blade.php swaps
   to the compact power mark below md (includes/logo vs includes/logo-plain),
   which is part of why the header clipped on a phone.

3. brands.vue offered delete. develop has no brand delete anywhere - no route,
   no control, no controller method - so the workflow was invented outright.
   allow-delete is now false for that page; AdminCrudTable keeps the
   capability for the pages that legitimately have it.

Recorded as accepted deviations rather than reverted:
- Uppy for profile photo and network logo, where develop has a plain file
  input. Standing instruction from the product owner: all photo uploads go
  through Uppy.
- AdminCrudTable's delete confirmation. develop deletes on an unconfirmed GET
  link; removing a confirmation step from a destructive action to match that
  is not a sensible reading of parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
GroupEventScrollTable.vue is a b-table with every column sortable. Only the
date header sorted here, so the stats columns restored in b1877c9 were
decorative - a host could see participants or CO2 per event but not order by
them, which is most of why you would look at the table.

All columns now sort, first click ascending and the active one flipping,
matching b-table. Missing stats sort as -1 rather than 0: "not recorded" and
"a recorded zero" are different, and interleaving them would misreport which
events had nothing logged.

Caught while wiring it: the existing header's `@click="toggleSort"` passed the
MouseEvent as the new sort key once toggleSort took an argument, which
silently broke sorting entirely. Its test failed immediately, which is the
only reason it did not ship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The bulk of the 105-item parity audit, fixed in twelve concurrent batches with
disjoint file ownership, then a single strings pass (sole owner of lang/**) and
a verification pass. Every fix was made by reading develop's actual markup/CSS
for the element and matching it - the discipline whose absence produced these
deviations in the first place.

Highlights by root cause:

RC1 control-substitution: the native controls that stood in for develop's
vue-multiselect are now TagMultiselect (DashboardAddData, EventForm,
EventFilters, GroupsTableFilters, DeviceForm). Date filters use
vue-datepicker-next, the same library Freegle uses and which we already depend
on. Every string these needed was taken verbatim from develop's lang files, not
invented.

RC2 orphaned CSS / rewritten markup: doodle icons restored to their native SVG
dimensions (develop sizes most of them not at all); Talk-topic comic-card
spacing and hover box-shadow restored from DiscourseTopic.vue; row dividers
given develop's solid-black hr instead of Bootstrap grey (with the !important
BS5's border utilities require); onboarding modal restyled from _onboarding.scss
against its own class names.

RC3 invented strings replaced with develop's verbatim: forbidden.title,
moderate_approve_help (both traced to develop source - forbidden.blade.php and
EventAddEdit.vue:84), and 17 keys added rather than made up.

RC4 dropped behaviours restored: whole-row click targets, the onboarding close X
gated to the last slide, Previous hidden not disabled on the first.

Two dead keys removed, both now genuinely unreferenced:
- dashboard.getting_the_most_intro - this reverts my own earlier commit
  3b9d4ce, which added it as "visible at all widths". It is not: develop wraps
  it in a d-none d-md-block <p> whose only child is d-inline d-md-none - mobile
  content in a desktop-only container, invisible at every width. The identical
  dead-markup pattern I correctly caught for sidebar_help in the same file and
  then missed here.
- admin.role_permissions_help - develop's role-edit modal uses a literal
  untranslated paragraph; no lang key is referenced there.

Tests: several specs asserted our old (wrong) behaviour and were corrected to
develop's, not weakened - the onboarding close-on-last-slide test, the
whole-row-link test, and the native-select-to-TagMultiselect driver changes.
Full client suite 1568 passing; the 2 failures are the known useStatsShareImage
container-mount artifact that passes in CI. translations:check exits 0, eslint
0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
…in the 88 fixes

An exhaustive verification pass re-read all 88 fixes from ccc4b84 against
develop. 77 matched; 11 diverged - the fix had invented or mis-copied a value
and shipped it under a plausible note, which is exactly the failure mode the
whole parity effort was cleaning up, recurring in the cleanup. Sampling would
have missed all 11.

Corrected against develop's actual source:
- DashboardOnboardingModal: nav buttons bottom:25px/left:40px/right:40px and
  finish bottom:32px (were 0/0); slide dots 15x15px #81CAD3 active #000 (were
  8px #d8d8d8/#222); modal max-width 615px at lg (was ~500). From
  _onboarding.scss and _slideshow.scss.
- RichTextEditor: pasted <img>/<table>/<iframe> were embedded; develop's
  quill-paste-smart strips every tag outside [a,b,strong,u,s,i,p,br,ul,ol,li,
  span,h4,h5,h6]. Added the tag allowlist matcher (text kept, embed dropped).
  Security-relevant - this was a real sanitisation hole, not just styling.
- DevicesSearchTable brand filter: native <datalist> replaced with the custom
  suggestion panel DeviceForm already built and the codebase deliberately
  chose over datalist for parity with vue-typeahead-bootstrap.
- LocaleSwitcher: restored _languages.scss's position:fixed;bottom:0 on the
  login/guest layout with 65px page padding.
- AppNotifications: coloured pill badges (Patua One, badge-left/right padding)
  from _badge.scss, were transparent.
- CalendarsTab: the .span-vertically-align-middle class was referenced in
  markup but never ported - added it (and the cog before "All events").
- networks/[id] coordinator card border #000 -> #222 and hover colour.
- AdminCrudTable .edit-panel__device padding rule from _edit.scss.
- Two *-icon-brand.svg assets were missing from client/public entirely, so
  correct markup rendered a broken image - copied from the Laravel public dir.

NOT applied - recorded accepted non-match: category.vue weight/footprint/name
column labels. develop's CategoriesTable.vue hardcodes English headers
bypassing its own lang file; matching literally would delete our fr/fr-BE.
Reverted the batch's name->'Name' change and its test to keep all three
translated, consistent with 1eb9863.

Full client suite 1569 passing (2 known useStatsShareImage container-mount
failures), translations:check 0, eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
e2e-client was timing out - not slowly, but hanging. grouptags.test.js's
network-tag helpers waited 360s for getByTestId('tag-add-button'), never found
it, retried, and blew the whole job's wall clock, taking all e2e signal down
with it.

Root cause, and it is NOT this session's parity work: the network-tag section
was written for a modal CRUD UI (tag-add-button -> tag-create-modal ->
tag-create-submit, tag-edit-modal, tag-delete-modal) with testid-prefix "tag".
The actual component, NetworkTagsManager.vue, has always been a bespoke
inline create form (network-tags-create-*) plus edit/delete modals
(network-tags-edit-*/network-tags-delete-*). git log --all -S "tag-add-button"
finds it nowhere in client/app - it never existed. My one touch of this file
(ccc4b84) changed two icons and a background colour, no testids; the mismatch
predates the whole migration branch, which is why e2e-client was already
timing out on pipeline #4894.

Rewrote the network-tag helpers and test bodies to drive the component that
actually renders - the same fix applied to party/index.spec.js earlier this
session: address the real control, not one that was never built. Create is an
always-visible inline form (no add-button, no create-modal - the redundant
"wait for create modal to close" assertions after each create are removed).
Edit and delete are modals, remapped to their real testids. Added the one
missing testid the flow needed: network-tags-delete-cancel on the delete
modal's cancel button.

Every remapped testid verified present in NetworkTagsManager.vue /
networks/[id].vue; the create/update/delete API URLs and methods the helpers
wait on already matched the component's store actions. eslint clean.

NOT run locally: a background job holds the shared test DB, and running
Playwright into it would corrupt both. CI validates the e2e; global-tag and
group-tag helpers in the same file were already correct and untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Four issues from the preview, all confirmed against develop.

1. Dead session shows a dashboard error instead of redirecting to login.
   The console evidence (GET /api/v2/users/me/events 401, "/" redirecting to
   /dashboard) is a token present in localStorage - loggedIn (=!!token) true,
   so the guard admits it - while the session behind it is invalid, common on
   a preview whose DB was reset so the token maps to a deleted user. BaseAPI
   only clears auth on a /session *401*; a 500, or a /session that returns a
   null user for a bad token, left the token in place. The cold-boot session
   plugin now clears the token on ANY failed resolve OR a null-user response,
   so the whole app - guard, navbar, the "/" redirect - consistently treats it
   as a guest and routes to /login. Two tests pin this.

2. Logged-out Sign in / Join buttons. develop writes plain anchors with inline
   styles the .btn classes were fighting: square corners (we rendered a pill
   radius), a 10px gap between them (they were touching), Sign in white with a
   2px black border, Join solid black. All re-asserted to match, sentence case
   per develop's text-transform: initial.

3. Language bar was not full width - AppFooter wrapped it in Bootstrap's
   .container, capping it to the content max-width. Removed; the bar is now
   width:100% with develop's top border, matching _languages.scss.

4. Language bar sat mid-page on short pages instead of pinned to the bottom.
   develop sets <body class="fixed-layout"> in BOTH header.blade.php (app
   pages) and header_plain.blade.php (guest), and .fixed-layout .language-bar
   is position:fixed;bottom:0;z-index:10 - i.e. pinned on every page, with the
   body padded 65px so it never covers content. An earlier fix scoped this to
   guest pages only; generalised it to every page as develop does.

The dashboard error box (#2 of the reported set) is left as-is: once the dead
session redirects to /login it no longer appears in that flow, and develop
server-renders the dashboard so there is no SPA error box to match against.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
When no groups are in the map view, the count line read "There are 0 groups.
Zoom out to see more." - technically true but a dead end. It now reads "If you
can't see any here yet, why not find a group near you?", with "find a group"
linking to /group/nearby.

Not a develop parity change - develop has no group_count_map string at all,
this whole count line is ours - so this is a UX improvement, and the friendly
copy plus link is the requested behaviour.

The link uses <i18n-t> with a #link slot rather than a raw <a> in v-html: it
keeps the link's position translatable (it moves in the French sentence) and
gives a real client-side NuxtLink instead of a full page reload. Same pattern
as party/edit's "Editing <name>" heading. Non-zero counts keep the existing
pluralised count string. fr/fr-BE added, using the map's own "Repair Cafe"
wording for the link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
Two correctness fixes from the RES-1995 map-of-groups work (PR 887), still
unmerged to develop, that the nuxt branch's own rewrites had dropped.

1. GroupSummary next_event dropped the approved filter. The base intent -
   Group::getNextUpcomingEvent - selects the next event WHERE approved=true.
   The bulk-cached summary rewrite (for the map, which fetches many groups at
   once) cached Party::future()->get() with no approval filter, so a
   pending-moderation event could surface as a group's public next event. Now
   filters approved=true, and the cache key is future_approved_events to match
   (the old key would otherwise serve stale all-events data). The comment
   already said "next approved event" - the code just didn't.

2. Party::scopeFuture now reorder()s before its orderBy. orderBy appends, so a
   parent scope's existing ORDER BY (e.g. DESC) stacked into
   "... DESC, ... ASC" with DESC winning, making future() return the LAST
   future event first - so a next-event lookup picked the wrong one.

Test asserts an unapproved future event does NOT become a group's next_event;
the existing tests only checked approved ones appear. The stale
future_events cache key in the test is updated to the new name.

Not runnable locally right now (a background job holds the shared test DB);
CI validates. develop is exactly at the 887/nuxt merge-base, so 887 has not
landed there yet - this brings its fix into 898 early rather than regressing
the map when it does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
PR 887 (RES-1995 map of groups) is unmerged and develop sits exactly at its
merge-base, so its wording never reached the nuxt branch - and my ac4f5a5
"drift" sync had actually reverted some of it toward the stale develop values.
This brings 887's group/map copy into 898.

Filter bar (used by list and map): search placeholders -> "Search by name",
"Search by location", "Filter by country", "Filter by tags"; "Show filters" /
"Hide filters" (sentence case). This reverses the direction of ac4f5a5 for
these keys - 887, not the current develop, is where the project moved.

Nearby/find tab: groups_title2 "Other groups nearby" -> "Find a group".

Map count line: adopts 887's wording - "There is N group in this area. Search
and zoom to find more." - and its zero-state group_count_none ("...why not
find a group near you?", linking to /group/nearby). My last two commits had
built ad-hoc versions of exactly these (group_count_map_empty, find_a_group,
the i18n-t/NuxtLink zero-state); those keys are removed and the map page now
uses 887's group_count_none verbatim, rendered via v-html since 887 carries
the link inline. The rendered text is what those two commits already showed -
this just converges on 887's actual keys so the two branches don't collide in
lang/groups.php when both reach develop.

Deliberately NOT changed: /group/all's own group_count stays a plain "There
are N groups." - 887's "in this area, search and zoom" belongs to the map,
and 898 split develop's single /group page into separate list/map/nearby
routes where that wording would be wrong on the list. groups_title2_mobile is
not added: 898 references it nowhere and translations:check rejects unused
keys.

fr/fr-BE taken from 887 verbatim (it uses "Repair Cafe" for groups on these
pages). Specs asserting the old labels updated to 887's; full client suite
1572 passing (2 known useStatsShareImage container failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The one 887 feature 898 had deliberately dropped: clicking a map marker opens
a modal with the group's next event and a "Go to group" action, rather than a
bare popup link. 898's comment said a Vue modal "isn't reachable from inside a
marker's click handler without extra plumbing" - this is that plumbing.

The seam: markers are imperative Leaflet layers (for clustering), so
GroupMap's marker click emits `select` with the group id; the page turns that
into a GroupInfoModal for the selected group's summary, and clears it on
close. GroupInfoModal ports develop's component to Vue 3 / bootstrap-vue-next:
header (image + name + location) linked to the group, "Next event: <date>
<title>" or "None planned", and Go to group / Close. Date formatted exactly
like GroupsTable's dateLabel so the map and list agree. The redundant marker
popup is removed (887 has the modal, not both), which also drops the now-unused
escapeHtml helper.

Built test-first: GroupInfoModal.spec (6 - render/next-event/none-planned/goto/
close), GroupMap.spec (marker click emits select with the id), map.spec (select
opens the modal for that group; close clears it). Each failed before the
implementation. goto_group/next_event lang keys added from 887 (fr/fr-BE too).

Full client suite 154 files, only the 2 known useStatsShareImage container
failures; eslint clean; translations:check 0. The backend approved-next-event
fix (c51f096) is confirmed live: GET /groups/summary?includeNextEvent returns
the seeded approved future event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ye3otsxm9RiHHDKcC3TJA
The marker-info modal showed the location as {{ group.location }}, but the
summary API returns location as a GroupLocation object ({location, area,
postcode, country, country_code, lat, lng}) - so the modal leaked the whole
JSON blob into the header. develop's GroupInfoModal renders
group.location.location; match it. The unit fixture used a plain string so
the bug never surfaced in tests; browser verification (WSL chromium, GPU
disabled) caught it. Fixture now uses the real object shape and asserts no
raw object leaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
develop had its own deploy and teardown path, separate from PR previews:
a dedicated fly.dev.toml, a long-lived restarters-dev-db MySQL app, and a
nightly GitHub Actions job to stop a machine that could not suspend itself
(4GB + swap; swap disables Fly's suspend). Previews already had a better
answer - disposable embedded database, suspend on idle, wake on request.

restarters-dev is now a preview in every respect. fly.pr.toml becomes the
shared template fly.preview.toml with four placeholders (__APP_NAME__,
__APP_TITLE__, __BASE_URL__, __SENTRY_ENV__), substituted identically by
pr-preview.yml and CircleCI's deploy-fly-dev. The develop deploy keeps its
existing test gate and now polls /_preview_status, failing the build if the
restore or the branch's migrations fail - previously a broken migration
produced a quietly broken dev site.

Deleted: fly.dev.toml, fly-mysql-dev.toml, stop-dev-nightly.yml, and the
admin "Deploy Preview Branch" tooling (page, controller, routes, specs,
six orphaned translation keys). That tooling dispatched preview-deploy.yml,
which was deleted in 3db7b1c, so the button had been failing since.

QUEUE_CONNECTION is database, not sync, for every preview. The supervisord
worker already ran `queue:work database` regardless, so sync only served to
block the dispatching request. Fly suspends on proxy inactivity rather than
CPU, so a polling worker does not hold the machine awake.

Base URL is restarters-dev.fly.dev, not restarters.dev: that hostname
resolves to an unrelated host and restarters-dev holds no certificate for
it, so the old fly.dev.toml's APP_URL was simply wrong.

.dockerignore: `node_modules` as a bare name only matches at the repo root,
so client/node_modules (~466MB) was uploaded to the builder on every deploy,
CI included. Use **/node_modules, and exclude local-only clutter that took a
working-tree deploy to 8.1GB across 358k files - past the builder's 8GB
uncompressed image limit. Context is now 561MB / 5,468 files.

Verified: 1575 client tests pass; translations:export-client --check is in
sync; the deployed app reports {"phase":"ready","restore_ok":true,
"migrate_ok":true} with the queue worker running and the db-backup and
restart-yesterday crons stripped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172K3nBv5WN34mB9taxLyWt
User feedback on the PR 887 map, applied to the Nuxt port:
- MAX_MAP_ZOOM 14 -> 18 (CARTO raster tiles serve z18): street names
  were unreadable and close-together pins couldn't be told apart.
- maxClusterRadius 80 -> 120 (MAP_CLUSTER_RADIUS): too many small
  cluster bubbles on screen at once; wider radius merges them into
  fewer, larger clusters.
- Port Freegle's ClusterMarker.vue identical-location separation:
  each subsequent group at the same exact coordinates is nudged by
  0.003 degrees into a display-only copy (never mutating the store's
  objects - Freegle's accumulation regression), with string coords
  coerced so the nudge is arithmetic, not concatenation.

TDD: all three changes test-first (useGroupMapGeometry.spec.js,
GroupMap.spec.js); full client suite green (1583 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
/group/map existed but was linked from nowhere: the tab bar deliberately
omitted it, citing pre-887 legacy's three tabs, and four specs pinned its
absence - while the groupmap e2e already expected groups-tab-map to be
active. PR 887 reworks the groups page so the map IS one of /group's
tabs, so it must be reachable from /group.

Rendered as a fourth tab rather than replacing nearby/all as 887 does,
because this port's /group/all carries the full-list filter bar and tag
badges that 887 folded into its map list panel (not ported into
pages/group/map.vue's list). Uses the already-present (en/fr/fr-BE)
client.groups.map_tab key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
User feedback: the 0.003-degree nudge put co-located pins streets apart,
reading as two genuinely different locations. Shrink it to 0.00015
(~28px lng / ~45px lat at z18, UK latitudes) so the pins only become
distinguishable right at max zoom.

That offset is well inside the 120px cluster radius, so also set
disableClusteringAtZoom to the map's max zoom - otherwise co-located
pins would render as a "2" cluster bubble even at z18 and never visibly
split (spiderfy-on-click was the only way to separate them).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
The port's /group kept pre-887 develop's three list tabs (mine/nearby/
all) with the map on an unlinked side route - the wrong baseline for a
branch integrating PR 887, whose GroupsPage.vue has exactly two tabs
with the map+list AS the "Find a group" tab. The previous commit's
fourth "Map" tab was a misreading of the same history; this puts the
structure right:

- GroupsTabsNav: two tabs - "Your Groups" (/group) and "Find a group"
  (groups_title2, /group/map).
- /group/nearby and /group/all redirect to /group/map (old links,
  bookmarks and map.vue's group_count_none inline link keep working);
  layout e2e now sweeps /group/map instead of /group/all.

Known follow-up, deliberately not smuggled in here: 887's map list
panel carries the full-list filter bar and tag badges (its GroupsTable
gets search/show-tags); the port's map list panel doesn't yet, so the
old /group/all extras (filters, tag badges, archived rows) are absent
until that's ported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
User feedback (matching the same change on PR 887's branch):
- Cluster bubbles now indicate how many groups they hold - 36/46/56px
  with a white -> amber -> orange saturation ramp at <10 / 10-99 /
  100+ - via a custom iconCreateFunction, replacing markercluster's
  default green/yellow/orange discs (MarkerCluster.Default.css no
  longer imported).
- Pins share the cluster's restart style (black border, white inner)
  as inline-SVG divIcons; the follow/hover states recolour the fill
  (green/red) instead of hue-rotating the stock blue PNG.

TDD; full client suite green (1562 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Same change as PR 887's branch: cluster border 5px -> 2px with the
pale yellow #FFF396 / amber #FFD16A / brand orange #F18F01 tier ramp;
pin SVG stroke 2.5 -> 2 with brand red #F45B69 on hover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Follow-up to 983bb23: the brand colours now live in _variables.scss
($brand-orange-official #F18F01, $brand-red #F45B69, and the
$map-cluster-*/$map-pin-* role tokens built on them) and GroupMap.vue
@uses them. The navbar notification badge, which already hardcoded the
same #f45b69, now reads $brand-red too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Same change as PR 887's branch, via the client's own
$font-family-sans-serif token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
User feedback: with scrollWheelZoom off, the wheel scrolled the page
instead of zooming the map, and people couldn't work out how to zoom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
df40a22 deleted the /group/nearby list page but left its three
now-unreferenced keys (nearest_groups, nearest_groups_change,
no_groups_nearest_with_location) in en/fr/fr-BE - translations:check
counts unused keys as errors and Translations\CheckTest requires exit
0, so CI's build job has been red since. Client locale exports
regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
The "upcoming" fixtures hardcoded 2026-08-20T10:00Z, which stopped
being the future mid-morning today - 15 tests flipped red with no code
change. Upcoming/later fixtures now derive from Date.now() (+30/+31
days), and the header test derives its expected day-of-month from the
fixture in the event's timezone instead of hardcoding "20".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Mirror of the same feature on PR 887's branch, in this stack:

- GET /api/v2/users/me/profile now carries the user's geocoded lat/lng
  (floats, null when unset) - the client had no numeric coordinates
  for the signed-in user at all.
- useGroupMapGeometry gains haversineKm (real km, unlike the ordering
  approximations - this number is shown to the user).
- GroupMap emits `searched` with the landed geocode centre.
- /group/map anchors to the searched place, else the profile
  coordinates, else hides the column; rows carry distance straight
  from the names index.
- GroupsTable: opt-in distance column (optionalColumns.distance) with
  a "km" header, 887's rounding (tenths under 5 km), click-sortable
  with unplaceable groups pinned last, and a new initialSortKey prop
  so the map page opens nearest-first.

TDD (12 new tests watched fail first); client suite green (1577),
APIv2UserProfileTest green (29).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Same fix as PR 887's branch, on the v3 promise-based geocoder API:
q=Haringey never surfaces the borough in Photon's default ranking, so
the search runs a place-layer-filtered query alongside the general one
and lists places first (deduped, capped at 10, failure on either side
degrades to the other).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
Only index.spec.js uses the second future date pair; no-unused-vars
failed the build-client Lint step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
The e2e-client CI job has been dying on CircleCI's 10-minute no-output
rule: several tests went stale against deliberate UI changes and each
burned 3 x 360s in silence. Verified locally per suite:

- brands round-trip: brand delete is deliberately absent (b17158c -
  develop has no brand delete anywhere); the test now pins the
  button's absence instead of waiting for it.
- global tags round-trip: delete moved into the edit form
  (638e285); final assertion counts the row rather than reading a
  table that is absent mid-refetch.
- device tests: the devices panel and its tables render twice
  (desktop + mobile collapsibles), so bare testids trip strict mode -
  scoped to the desktop panel in device.test.js and utils.addDevice;
  category/status/spare-parts became DeviceForm's plain-element
  multiselects, so the helper clicks control + option instead of
  selectOption, and the category assertion reads data-value.
- networks Add-groups: the item sits in the Actions dropdown - open it
  first instead of clicking an invisible menuitem forever.

Also a real regression this exposed: /group/all?network=N (linked from
the networks page Actions menu) redirected to /group/map WITHOUT the
query. The redirects now preserve it and /group/map applies ?network=
as the map's network filter.

Still genuinely failing (pre-existing, own session needed): the
grouptags setGroupTagAssignment cluster (a newly created network tag
never appears in the group form's tag checkboxes) and the Manager
network-logo-manage control.

Client suite green (1584); lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRv64fqMT3fAveD6NFgnmR
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)
E Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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

Labels

preview Deploy a Fly.io preview for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants