Skip to content

Admin/Frontend Shared Code Extraction TODO

Started from a Svelte best-practices review of the tracker fetch-status badge work (4cccce41, 74260a23, 18eb379e) that found trackerDisplay.ts byte-identical between tracker-admin-svelte and tracker-frontend-svelte. A full sweep of both apps' src/ trees (63 overlapping file paths, plus login/auth specifically) found the duplication is much larger than one file — including the entire login/auth stack, most modals, most of the API layer, and several core components.

Correction (2026-07-06): the assumption below that tracker-admin/Dockerfile and tracker-frontend/Dockerfile build these Svelte apps was wrong. Those Dockerfiles (and the admin/frontend targets in docker-bake.hcl, which is what make staging-build/deployment-pipeline.md actually invoke) point at ./tracker-admin and ./tracker-frontend — the old React/CRA app directories (craco.config.js, public/, index.html, output to /app/dist). There is no production build path for tracker-admin-svelte/tracker-frontend-svelte yet — they currently only run via the dev compose.yml services (admin-svelte-dev, frontend-svelte-dev, ports 3102/3101). So "moving to a workspace is safe from a deployment standpoint" is true, but there's no existing prod Dockerfile to update — creating one is new work (first-time cutover prep), tracked separately, not a consequence of this refactor.

Vite still bundles whatever source it's given at build time, so nothing here blocks a future production build from consuming packages/tracker-shared the same way the dev containers do — see Phase 0 verification below.

Small fixes (from the original review, unrelated to extraction) — done, 2026-07-06

  • ~~Remove legacy class: directive~~ Scope corrected, not done. A broader grep found class: used in ~20 files across both apps (almost all class:modal-closing={dialog.closing} in every modal, plus Sidebar.svelte, ThemeSelector.svelte). It's the established convention here for single-boolean→single-class toggles, not a one-off oversight — the original review only compared TrackerCard.svelte against itself. Also found a second instance the first pass missed: tracker-frontend-svelte/.../TrackerCard.svelte:53 (class:tracker-card__corner--checked). Fixing 2 of 22 occurrences would just create inconsistency; left all of them as-is. Revisit only if you want a dedicated, deliberate 20-file convention change — not a "small fix."

  • Deleted dead formatAgeLabel from trackerDisplay.ts in both apps.

  • Live-updating fetch-status badge — added a ticking $state clock (setInterval in an $effect, 60s) to the three components that call getFetchStatusBadge: admin TrackerCard.svelte, frontend TrackerCard.svelte, frontend TrackerListRow.svelte (admin's TrackerListRow.svelte doesn't use the badge at all, left untouched). $derived(getFetchStatusBadge(tracker, now)) now re-evaluates every tick instead of freezing at the last time tracker changed reference.

  • Style nits — fixed the .tracker-card__fetch-dot indentation, plus a second indentation drift in the same file found while in there ({:else}/{/if}/tooltip <div> were over-indented relative to their opening {#if}).

  • Verified: svelte-check clean (0 errors, same 2 pre-existing unrelated a11y warnings in AccountMenu.svelte) and all 46 tests pass in both apps after these changes.

Rough edge hit along the way: svelte-check/TypeScript couldn't resolve the extensionless specifiers (tracker-shared/pagination, /tone, /state/toast) through the wildcard "./*": "./src/*" exports map, even though Vite's own resolution (and vitest) handled it fine — TS doesn't do extension-probing through a matched wildcard pattern the way it does for plain relative imports. Fixed by adding explicit non-wildcard entries for those three in packages/tracker-shared/package.json (literal target with the .ts extension baked in), keeping the wildcard for everything else since .svelte imports already include their own extension in the specifier. Any future extensionless .ts export needs the same explicit treatment.

Phase 0 — Workspace scaffolding (done, 2026-07-06)

  • Root package.json with "workspaces": ["tracker-admin-svelte", "tracker-frontend-svelte", "packages/tracker-shared"].
  • Create packages/tracker-shared with its own minimal package.json ("exports": {"./*": "./src/*"} — consumed as raw TS/Svelte source directly by Vite, no build step). Declares svelte-hero-icons as a real dependency and svelte as a peer dependency rather than relying on hoisting.
  • Add "tracker-shared": "*" to both apps' package.json.
  • Both apps' vite.config.ts need optimizeDeps.exclude: ["tracker-shared"] and ssr.noExternal: ["tracker-shared"] — required because the package ships raw source (not pre-built) and SvelteKit still does an SSR pass at build/dev time even under adapter-static's fallback mode. Without these, esbuild's dep pre-bundler chokes on .svelte files and the SSR module runner treats the package as an external Node import it can't resolve.
  • Both apps' app.css need @source "../../packages/tracker-shared/src"; — Tailwind v4's automatic content detection only scans downward from each app's own directory, so it won't see a sibling package without an explicit @source.
  • Removed the two per-app package-lock.json (workspaces use one root lockfile now) and moved the stale root-owned node_modules in each app out of the way (they were created by the user: root dev containers; couldn't rm/mv across filesystems as a non-root host user, but an in-place rename on the same filesystem works since it only touches the parent directory entry).
  • compose.yml dev-container mounts had to change — this wasn't anticipated going in. admin-svelte-dev/frontend-svelte-dev previously bind-mounted only ./tracker-admin-svelte:/app / ./tracker-frontend-svelte:/app, so the container had no visibility into packages/tracker-shared or the root package.json needed for npm to recognize the workspace. Changed both to mount the whole repo root (.:/workspace) with working_dir: /workspace/tracker-admin-svelte (or -frontend-), matching the host's relative layout. Known rough edge: the container entrypoint's reinstall check (node_modules/@tailwindcss/vite existing relative to cwd) doesn't account for workspace hoisting putting that package in /workspace/node_modules instead, so it likely re-runs npm install on every container start. Not broken, just slower restarts — fix later if it becomes annoying.
  • Verified live: recreated both dev containers, npm install ran from the workspace root correctly, both vite dev servers started and served 200 on :3101/:3102 with tracker-shared imports (TS utils, a svelte/store-backed state module, and three Svelte components including one importing shared state via a relative path) resolving with no errors.
  • No production build path exists yet for either Svelte app (see correction note above) — creating tracker-admin-svelte's and tracker-frontend-svelte's own bake targets/Dockerfiles, wired to the workspace root, is separate future work whenever that cutover happens. Not blocking anything here.

Phase 1 — Move identical files (zero-risk, no reconciliation)

These 26 files are byte-for-byte identical between the two apps today. Move to packages/tracker-shared, delete both local copies, repoint imports. Splitting into a few PR-sized batches:

  • API layer (done, 2026-07-06): lib/api/auth.ts, lib/api/clients.ts, lib/api/client.ts, lib/api/types.tspackages/tracker-shared/src/api/. Bigger blast radius than the first slice: 7 other lib/api/*.ts files that are staying app-local (not identical between apps — brands.ts, trackers.ts, dark-trackers.ts, users.ts, dashboard.ts, locations.ts, production-runs.ts, plus frontend's reports.ts) import ./client/./types by relative path and had to be repointed to tracker-shared/api/client//types even though those files themselves aren't moving. Also needed explicit (non-wildcard) exports entries for all four, same TS-extension-resolution reason as the tone/pagination/toast fix from the first slice. Verified: svelte-check 0 errors, 46 tests pass, both builds succeed, both dev containers still serve 200 — all in both apps.
  • State (done, 2026-07-06): lib/state/auth.tspackages/tracker-shared/src/state/auth.ts (already imported only tracker-shared/api/auth//client plus svelte/store and $app/environment — no per-app dependency, clean move). 18 consumer files repointed. Verified: 0 svelte-check errors, 46 tests pass, both builds succeed, both containers 200.

  • Phase 2b — theme storageKey reconciled, themes.ts/index.ts/ ThemeSelector.svelte moved (done, 2026-07-07). Parameterized storageKey via a PUBLIC_THEME_STORAGE_KEY env var read through $env/dynamic/public (env.PUBLIC_THEME_STORAGE_KEY ?? "tracker-theme"), following the same pattern client.ts already used for VITE_API_URL/import.meta.env. Added the var to both apps' .env/.env.example (tracker-admin-theme/tracker-frontend-theme, preserving the exact pre-existing values). .env is gitignored (holds secrets like GOOGLE_CLIENT_SECRET), so those edits are local-only and don't ship with the PR — only .env.example is tracked. Anyone else pulling this branch needs to copy PUBLIC_THEME_STORAGE_KEY from .env.example into their own .env, or the fallback below kicks in for both apps instead of their distinct per-app keys. Also updated the pre-hydration inline theme-flash-prevention script in each app's app.html (not itself shared — per-app file, Phase 2 "mostly-shared" tier) from a hardcoded string literal to the SvelteKit %sveltekit.env.PUBLIC_THEME_STORAGE_KEY% placeholder, so there's one source of truth per app instead of the same string duplicated in two places. Verified concretely (not just build success) that each app's built index.html and live dev server render their own distinct storageKey — confirms behavior is fully preserved. ThemeSelector.svelte was then a clean move (byte-identical, only $lib/theme import, now resolved). Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.

  • Bug found by testing the missing-env-var case directly (not just "build succeeds"): SvelteKit's %sveltekit.env.X% is a build-time string substitution, not a JS expression — when unset it renders as an empty string, not undefined. That doesn't trigger themes.ts's ?? "tracker-theme" fallback (dynamic env vars are only undefined/??-triggering through $env/dynamic/public, not the static HTML placeholder). So the inline script would have read/written localStorage[""] while the app used localStorage["tracker-theme"] — two different keys, so the flash-prevention script would never see the theme the app actually saved (a flash on every reload) if the var were ever missing. Fixed with a matching JS-level fallback around the placeholder itself: "%sveltekit.env.X%" || "tracker-theme" — empty string is falsy, so this now matches the JS module's fallback, verified with an actual build with the var removed.

  • Utils/Components batch 2 (done, 2026-07-06): lib/utils/animatedDialog.svelte.ts, lib/utils/persistedJson.ts, lib/components/AccountMenu.svelte (moved earlier: lib/utils/pagination.ts ✅, lib/utils/tone.ts ✅, lib/components/Pagination.svelte ✅, lib/components/SortableHeaderButton.svelte ✅, lib/components/ToastHost.svelte ✅). AccountMenu.svelte is imported by Sidebar.svelte via a relative path (./AccountMenu.svelte), repointed to tracker-shared/components/AccountMenu.svelte. Verified: 0 svelte-check errors/warnings (the file's own a11y warnings no longer surface since it's outside either app's tsconfig scope now — expected, not a regression), 46 tests pass, both builds succeed, both containers 200.

Important correction — most of "Phase 1" as originally scoped isn't actually safe, regardless of being byte-identical. $lib/... is a per-app SvelteKit path alias; it does not resolve at all from a file living in packages/tracker-shared, even for type-only imports. So a byte-identical file is only a real Phase 1 candidate if it has zero $lib/... imports (or its only $lib/... imports point at something already moved to tracker-shared, rewritten accordingly). Checking the remaining list found most of it is blocked by exactly this:

  • Phase 2e, part 1 — trackers.ts + production-runs.ts reconciled and moved (done, 2026-07-07). Both were "admin is a strict superset" cases: admin's trackers.ts had everything frontend's had plus TrackerFull, createTracker, deleteTracker, importTrackersFromCsv, a hashed_advertisement_key field, and a buildQuery() helper; admin's production-runs.ts had the same shape plus extra ListProductionRunsParams filters (brand_id/parent_id/active_only) and a generalized buildQuery(). Moved admin's version of both to packages/tracker-shared/src/api/, deleted both per-app copies. Frontend's locally-named UpdateTrackerParams type had zero external consumers (only used inside its own trackers.ts), so it was dropped in favor of admin's equivalent TrackerUpdateInput — no call sites needed updating. Repointed 54 consumer files across both apps ($lib/api/trackers/$lib/api/production-runstracker-shared/api/...), plus one relative import (tracker-admin-svelte/lib/api/dark-trackers.ts imported TrackerStatus via ./trackers). Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • Now-unblocked batch moved (done, 2026-07-07): lib/utils/campaignGrouping.ts, lib/utils/matchTrackers.ts, lib/utils/trackerSelection.svelte.ts, lib/utils/trackerDisplay.ts, lib/components/campaigns/PasteSelectModal.sveltepackages/tracker-shared/src/ (components under src/components/). PasteSelectModal.svelte's own matchTrackers import repointed to tracker-shared/matchTrackers since it moved in the same batch. Test files (trackerDisplay.test.ts in both apps, campaignGrouping.test.ts/matchTrackers.test.ts in frontend only — admin has no counterparts for those two) stayed app-local since they're not identical between apps, just had their relative imports (./trackerDisplay etc.) repointed to tracker-shared/.... Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • Phase 2e, part 2 — brands.ts + SearchDropdown.svelte reconciled and moved, CampaignEditorModal.svelte unblocked and moved too (done, 2026-07-07). Both reconciliations were trivial: brands.ts differed by one doc comment only; SearchDropdown.svelte differed by admin's optional id/disabled props (frontend just never passed them, so adopting admin's superset is behavior-preserving). Moved admin's versions of both to packages/tracker-shared/src/api/ and src/components/. With those plus the earlier trackers.ts/ production-runs.ts/animatedDialog.svelte moves, CampaignEditorModal.svelte had zero remaining $lib/... imports and was byte-identical, so moved it too in the same PR. Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • lib/components/ThemeSelector.svelte — moved alongside the storageKey reconciliation above, see Phase 2b entry.

Status as of 2026-07-07: Phases 2a–2f are all fully done. Every file originally identified as blocked-not-just-unmoved has a resolution, and so does every "mostly-shared" item from the original audit (branding/ config parameterization, the Sidebar.svelte/nav-config batch, auth-flow SSO-error merge), including the highest-risk Phase 2f batch (RadialMoveWheel.svelte a11y, TrackerCard/TrackerListRow's edit/delete-vs-fetch-status-dot split, LocationEditorModal/ LocationsImportModal's fixed-production-run mode) — see the Phase 2 sections below for the details of each.

Practical implication: the API-layer reconciliation (trackers.ts/ production-runs.ts) was worth doing first exactly because it unlocked 5 other files in one move. brands.ts + SearchDropdown.svelte reconciliation (unlocked CampaignEditorModal.svelte) and the theme storageKey parameterization (unlocked ThemeSelector.svelte + theme/index.ts) were the two other unlocks of this kind — this pattern (find what's blocking the most other files, do that first) is worth repeating for any future large duplication cleanup.

Done as the proof-of-pattern slice (2026-07-06): tone.ts, pagination.ts, state/toast.ts, components/Pagination.svelte, components/SortableHeaderButton.svelte, components/ToastHost.svelte. Chosen because all six were fully self-contained (no dependency on the divergent Tracker/API types), giving one file each of: a plain TS util, a constant, a svelte/store-backed state module, and Svelte components including one consuming shared state via a relative import. Verified live in both dev containers — see Phase 0. One relative-import site was missed on the first pass (tracker-frontend-svelte/src/lib/utils/dashboardFilters.ts imported pagination.ts via "./pagination" rather than the $lib/... alias, which the initial grep sweep didn't catch) and fixed once the dev server surfaced the SSR error — worth re-grepping for bare relative imports (from "\./) in addition to $lib/... aliases before deleting a moved file's local copy next time.

  • Root/misc — resolved as N/A, 2026-07-07: app.d.ts, app.css, routes/(auth)/login/+page.server.ts, routes/+layout.svelte are all byte-identical between the apps today (re-verified), but none of the four can actually move, for two different reasons: routes/(auth)/login/+page.server.ts and routes/+layout.svelte are SvelteKit route files (routing is per-app, same constraint as the Phase 2a title-text pages). app.d.ts and app.css are different — not routing, but SvelteKit/Tailwind tooling conventions: app.d.ts must live at src/app.d.ts for SvelteKit's ambient-type system to pick it up per app, and app.css's @source directive is resolved relative to its own location — if app.css moved into packages/tracker-shared, Tailwind's content detection would start scanning from there instead of each app's own src/, breaking class detection for every other component in that app. No action needed; identical-by-coincidence isn't the same as shareable.

Phase 2 — Reconcile "mostly-shared" files (same structure, per-app diffs)

2a. Trivial diffs — comment-only or <title>-text-only (done, 2026-07-07)

  • ~~lib/api/brands.ts (comment-only)~~ done — moved as part of the Phase 2e brands/SearchDropdown PR, see below
  • Comment-only component/util batch moved (done, 2026-07-07): lib/components/campaigns/MoveTrackerModal.svelte, lib/components/clients/BrandEditorModal.svelte, lib/components/clients/ClientEditorModal.svelte, lib/components/ConfirmDeleteModal.svelte, lib/utils/moveWheelTargets.ts, lib/utils/pointerDrag.svelte.tspackages/tracker-shared/src/. Each pair differed only by which side had more explanatory comments (or, for ClientEditorModal.svelte, just an <h2> line-wrap style); picked whichever side was the comment superset as the source, no content lost. Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • ~~routes/(auth)/forgot-password/+page.svelte (title text)~~ resolved as N/A, see note below — not moving
  • ~~routes/(auth)/reset-password/+page.svelte (title text)~~ resolved as N/A, see note below — not moving
  • ~~routes/(app)/profile/+page.svelte (title text)~~ resolved as N/A, see note below — not moving
  • ~~routes/+error.svelte (title text)~~ resolved as N/A, see note below — not moving
  • lib/utils/trackerDisplay.test.ts (done, 2026-07-07) — synced admin's fixture to match frontend's extra last_processed_at field (inert either way, neither test asserts on it). Now byte-identical in both apps, but stays app-local, not moved: each app's vitest only globs its own src/**, not into a workspace-linked package's node_modules symlink, so a test file living in packages/tracker-shared wouldn't actually get run by either app without separate test-runner setup for the package itself — out of scope here. Verified: 4/4 tests still pass.

Note on the 4 remaining title-text pages: these are SvelteKit route files, which can't move to packages/tracker-shared at all (routing is per-app). "Reconciling" the title string wouldn't reduce duplication — the page file stays duplicated either way — so it's not worth the churn. Leaving as-is; not a Phase 2a candidate despite the original listing.

2b. Branding/config — needs a small config object or props (done, 2026-07-07)

  • ~~app.html + inline theme-flash script — parameterize theme name/order and default fallback theme per app~~ done — the storageKey piece is done (see Phase 2b above); theme name/order/default-fallback differences are still separate and unaddressed, left as-is intentionally (each app's theme list order reflects that app's own preference, not an oversight)
  • ~~lib/theme/themes.ts — parameterize storageKey~~ done, see Phase 2b above
  • Done (2026-07-07): lib/components/AppShell.svelte, lib/components/AuthShell.svelte, lib/components/Sidebar.sveltepackages/tracker-shared/src/components/. Sidebar.svelte needed the most surgery since it's the one that's genuinely per-app config (nav items), not just a differing string:
  • Sidebar now takes appName, tagline, navigation: NavItem[] props (NavItem exported alongside the default export, same pattern as SearchDropdownOption)
  • AppShell takes appName, tagline, metaDescription, navigation, threading the first three + navigation down to its two <Sidebar> instances (desktop rail + mobile drawer)
  • AuthShell takes appName, description
  • Each app's nav items (icons included) and branding strings now live in their own (app)/+layout.svelte/(auth)/+layout.svelte — the one place per app that's inherently non-shareable anyway, so this doesn't introduce new per-app files, just moves the literals from the component to its one call site
  • AppShell.svelte's <meta name="description"> and the header's subtitle turned out to be three distinct strings per app, not two (page <title>/header-name share appName, but metaDescription and tagline differ from each other) — caught by re-reading the original diff carefully rather than assuming a 2-prop shape
  • Verified concretely: grepped both apps' +layout.svelte call sites side-by-side against the original diff values to rule out a transcription error, since the client-gated {#if $authState.isLoading} wrapper means the actual rendered title isn't visible to a static curl check (same limitation as the 2d SSO verification). 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.

2c. Small prop additions — near-identical, one side has an extra optional prop (done, 2026-07-07)

  • Done (2026-07-07): lib/components/StatusBadge.svelte (took frontend's superset with the title prop), lib/components/clients/EntityCard.svelte (frontend's superset with editDisabledReason), lib/components/clients/EntityColumn.svelte (frontend's superset with editDisabledReason/addDisabledReason; its relative import of EntityCard.svelte repointed to tracker-shared/... since both moved together) → packages/tracker-shared/src/components/ (clients/ subfolder for the latter two). Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • Done (2026-07-07): lib/components/LoadingIndicator.svelte (admin) / lib/components/dashboard/MapLoadingIndicator.svelte (frontend) merged into one LoadingIndicator.svelte in packages/tracker-shared. The CSS-var/class prefix (app-loader/map-loader) turned out to be a pure internal implementation detail — nothing outside either component referenced those class names — so no prefix prop was needed; just standardized on admin's app-loader naming and kept the existing label prop (every call site already passes its own label explicitly, so the differing default value never mattered in practice). Repointed all 6 consumers (2 admin, 4 frontend); each keeps importing under whichever local name it prefers (LoadingIndicator or MapLoadingIndicator) since the import identifier is independent of the underlying file/export name.
  • ~~lib/components/SearchDropdown.svelte — admin's id/disabled props are a superset; adopt admin's version~~ done, see Phase 2e part 2 above

2d. Auth flow — merge frontend's SSO-error handling into the shared version (done, 2026-07-07)

  • routes/(auth)/+layout.svelte — resolved automatically once the 2c loader merge landed; now differs only by local import identifier (LoadingIndicator vs MapLoadingIndicator) for the same underlying shared file. Route files can't move to packages/tracker-shared anyway, so left as-is — this is as reconciled as it can get.
  • routes/(auth)/login/+page.sveltebackported frontend's SSO provider-error query-param handling (?error=...PROVIDER_ERROR_MESSAGES lookup → initialError prop) into admin's copy, verbatim. This is a real behavior addition for admin, not just deduplication — admin shares the same OIDC backend, so a provider error redirect landing on /login?error=... was previously silently ignored there. Both apps' login pages are now byte-identical (still can't move to shared — route files).
  • lib/components/auth/LoginForm.svelte — moved frontend's superset (with initialError) to packages/tracker-shared/src/components/auth/. No $lib blockers, only the two login pages consume it.

Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200. Curl-tested the new /login?error=not_permitted behavior directly — output was identical between admin and frontend (both show the same static-shell artifact under curl, since adapter-static's SPA fallback serves the same shell regardless of query string and the actual error-message rendering happens client-side post-hydration), confirming parity with frontend's already-proven-working implementation rather than a guess.

2e. API layer reconciliation (bigger diffs, real behavior differences) (done, 2026-07-07)

  • Done (2026-07-07): lib/api/locations.ts — admin's version (adds optional production_run_id filter param to ListLocationsParams, listDeliveryLocations, listStorageLocations) adopted as the superset → packages/tracker-shared/src/api/locations.ts.
  • ~~lib/api/production-runs.ts — admin factors query-building into a buildQuery() helper~~ done, see Phase 2e part 1 above
  • ~~lib/api/trackers.ts — biggest reconciliation in this phase~~ done, see Phase 2e part 1 above
  • Done (2026-07-07): lib/utils/campaignDisplay.ts (admin) vs lib/utils/campaignPermissions.ts (frontend) — cross-referencing found a bigger mess than the doc anticipated. Frontend actually had two files: its own campaignDisplay.ts (3 functions) was a byte-identical-on-overlap subset of frontend's own campaignPermissions.ts (9 functions) — a within-app duplication bug, not just cross-app. Admin's campaignDisplay.ts (5 functions: campaignStatus, isParentBatch, hasEnded, addChildRunTooltip, formatCampaignDate) is a strict subset of frontend's campaignPermissions.ts, which adds canEditCampaign/editTooltip/canDeleteCampaign/deleteTooltip role-gating logic admin doesn't need (per admin's own comment: "every user who reaches this app is already an admin"). Moved frontend's campaignPermissions.ts (the true superset across both apps) to packages/tracker-shared/src/campaignPermissions.ts, deleted all three original files (admin's campaignDisplay.ts, frontend's redundant campaignDisplay.ts, frontend's moved campaignPermissions.ts), repointed 3 consumers + 1 test file. Verified: 0 svelte-check errors/warnings, 46 tests pass (including the 15-test campaignPermissions.test.ts suite now exercising the shared module), both builds succeed, both containers 200.

2f. Real UI reconciliation — biggest risk, treat as its own task

  • New feature added alongside the TrackerCard/TrackerListRow merge (not originally scoped in this doc): a card-view sort dropdown (Name / MAC address (default) / Last reported) in all three tracker list pages, plus a matching sortable "Status" table header (last_report_received) for list view. Sort choice persists via localStorage using the existing loadPersistedJson/ savePersistedJson pattern (same as the pre-existing pageSize persistence), keyed per app/page (e.g. campaign-run-tracker-sort, frontend-tracker-sort).
  • Done (2026-07-07): lib/components/campaigns/RadialMoveWheel.sveltepackages/tracker-shared/src/components/. The isOverHub "extra hover-state logic" the original audit flagged turned out to already exist identically in both files (used to derive activeTargetId) — frontend's template just referenced isOverHub directly in the parent-hub class ternary instead of re-deriving the equivalent activeTargetId === parentTarget.id && !parentDisabled admin used, a cleaner expression of the same logic, not a behavior difference. Used frontend's version as the base (it also had substantially more explanatory comments throughout — spoke entrance/exit physics, the |global transition requirement, the pointercancel handling), then ported admin's handleTargetKeydown function and the role="button"/tabindex="0"/onkeydown/ onclick attributes onto both the spoke divs and the parent hub div — a pure accessibility addition, no behavior change for either app's existing pointer-drag interaction. Verified: 0 svelte-check errors/warnings, 46 tests pass, both builds succeed, both containers 200.
  • lib/components/campaigns/TrackerCard.svelte (done) — resolved the edit/delete-vs-fetch-status-dot split per explicit product direction: dropped admin's checkbox-style selection entirely in favor of frontend's corner marker, made edit/delete hover-reveal buttons positioned left of the corner (admin-only, gated by optional onEdit/onDelete props), and merged the separate fetch-status dot into the corner itself as a muted color-mix gradient tint (color = fetch status, always visible to every viewer; checkmark = selection, only meaningful/interactive for manager+ roles via canMove). Permission model: view = user role, select/move = manager role, edit/delete = admin role.
  • lib/components/campaigns/TrackerListRow.svelte (done) — same resolution as TrackerCard, but keeps the real functional <input type="checkbox"> for row selection (table rows don't have a card corner) alongside frontend's fetch-status dot and admin's hover-reveal edit/delete buttons.
  • lib/components/locations/LocationEditorModal.svelte (done) — adopted admin's version, which adds a "fixed production run/brand" locked read-only display (shown when defaultProductionRunId/ location.production_run_id resolves to a value) in place of the free-choice brand/campaign SearchDropdowns. Confirmed both apps' locations/+page.svelte only ever populate the fixed-mode props from a campaign_id URL search param (a genuinely fixed context, not an interactive in-page filter — both pages already hide their filter UI in that case via showFilters/equivalent), so frontend gains the same locked display in that scoped view with no loss of its general free-choice behavior elsewhere. Required adding leaflet/leaflet-geosearch as real dependencies of packages/tracker-shared/package.json (previously only declared by each app directly) — Vite's SSR build resolves a workspace package's own further imports (including CSS) relative to that package's package.json, not the consuming app's, so the raw leaflet/dist/leaflet.css import failed to resolve during vite build until declared there; npm install picked it up via hoisting and both dev servers needed a one-time restart to drop their stale module graph.
  • lib/components/locations/LocationsImportModal.svelte (done) — same fixed/locked-context pattern and reasoning as LocationEditorModal above; no extra dependencies needed since it has no map/CSS imports of its own.
  • lib/components/locations/LocationCopyModal.svelte (done, not in the original audit) — this one was admin-only; frontend's locations/+page.svelte had no "copy locations from a related campaign" affordance at all, a genuine feature gap rather than duplication. Moved the component to packages/tracker-shared and ported admin's copySourceProductionRuns derivation (siblings sharing the same parent campaign) plus its copyLocations handler (paginated fetch of the source campaign's locations, then best-effort per-location create*Location calls) onto frontend's page — frontend's selectedProductionRun is always URL-param-scoped (no in-page filter UI), so the "copy" button's copySourceProductionRuns.length > 0 guard behaves identically to admin's. Also brought frontend's per-card Import/Export CSV buttons from btn-outline to btn-primary to match the "Add location"/"Copy locations" buttons above them, mirroring admin's existing (consistent) styling.

Phase 3 — Leave these alone (structurally different, not shareable)

No action — documented so nobody re-litigates trying to merge them:

  • routes/(app)/+layout.svelte — admin adds a role gate (isAdmin check + "Access denied" screen); frontend has none
  • routes/(app)/+page.svelte — admin dashboard (calendar heatmap, stats) vs. frontend live map/table — different features entirely
  • routes/(app)/trackers/[id]/+page.svelte — admin adds full tracker CRUD; frontend is read-mostly
  • routes/(app)/locations/+page.svelte — admin has considerably more production-run-scoped logic

Their shared sub-pieces (API calls, modals, list/card components) are already covered by Phases 1–2 above.

Verify

  • npm run build for both apps produces working, independent build-dist output with no shared runtime dependency — confirmed in Phase 0 (tone.js/Pagination.js chunks inlined into each app's own build output) and re-confirmed on every PR since.
  • Both Docker images still build and serve correctly from the new root-context build — N/A for now, not a stale checkbox: there is no production Dockerfile for either Svelte app yet (see the correction note near the top), so there's nothing to verify here until that first-time cutover happens.
  • npm run check / vitest pass in both apps after import changes — verified on literally every PR in this effort (0 svelte-check errors, 46 tests passing), not just once at the start.

Notes

  • Files unique to one app (admin: dashboard/users/dark-trackers/CSV import features; frontend: map/reports/campaign-management features) are out of scope — genuinely app-specific, not duplication.
  • The Dockerfiles already look like leftovers from a pre-Svelte (CRA/craco) setup — worth a separate look, out of scope here.
  • The small fixes, Phase 0, Phase 1, and Phases 2a–2f all landed as independent PRs (#25–#44 on migration-to-svelte-5) since each batch was self-contained — that pattern held up well across ~20 PRs.
  • Phase 2f (and with it, this entire extraction effort) is done. It carried the most behavioral risk of anything in this doc since it required real design decisions, not mechanical merges — TrackerCard/TrackerListRow's edit/delete vs. fetch-status-dot split especially needed a product call, not just an engineering one. All five items (RadialMoveWheel.svelte, TrackerCard.svelte, TrackerListRow.svelte, LocationEditorModal.svelte, LocationsImportModal.svelte) are resolved and merged.