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 foundclass:used in ~20 files across both apps (almost allclass:modal-closing={dialog.closing}in every modal, plusSidebar.svelte,ThemeSelector.svelte). It's the established convention here for single-boolean→single-class toggles, not a one-off oversight — the original review only comparedTrackerCard.svelteagainst 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
formatAgeLabelfromtrackerDisplay.tsin both apps. -
Live-updating fetch-status badge — added a ticking
$stateclock (setIntervalin an$effect, 60s) to the three components that callgetFetchStatusBadge: adminTrackerCard.svelte, frontendTrackerCard.svelte, frontendTrackerListRow.svelte(admin'sTrackerListRow.sveltedoesn't use the badge at all, left untouched).$derived(getFetchStatusBadge(tracker, now))now re-evaluates every tick instead of freezing at the last timetrackerchanged reference. -
Style nits — fixed the
.tracker-card__fetch-dotindentation, 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-checkclean (0 errors, same 2 pre-existing unrelated a11y warnings inAccountMenu.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.jsonwith"workspaces": ["tracker-admin-svelte", "tracker-frontend-svelte", "packages/tracker-shared"]. - Create
packages/tracker-sharedwith its own minimalpackage.json("exports": {"./*": "./src/*"}— consumed as raw TS/Svelte source directly by Vite, no build step). Declaressvelte-hero-iconsas a real dependency andsvelteas a peer dependency rather than relying on hoisting. - Add
"tracker-shared": "*"to both apps'package.json. - Both apps'
vite.config.tsneedoptimizeDeps.exclude: ["tracker-shared"]andssr.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 underadapter-static's fallback mode. Without these, esbuild's dep pre-bundler chokes on.sveltefiles and the SSR module runner treats the package as an external Node import it can't resolve. - Both apps'
app.cssneed@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-ownednode_modulesin each app out of the way (they were created by theuser: rootdev containers; couldn'trm/mvacross 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.ymldev-container mounts had to change — this wasn't anticipated going in.admin-svelte-dev/frontend-svelte-devpreviously bind-mounted only./tracker-admin-svelte:/app/./tracker-frontend-svelte:/app, so the container had no visibility intopackages/tracker-sharedor the rootpackage.jsonneeded for npm to recognize the workspace. Changed both to mount the whole repo root (.:/workspace) withworking_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/viteexisting relative to cwd) doesn't account for workspace hoisting putting that package in/workspace/node_modulesinstead, so it likely re-runsnpm installon every container start. Not broken, just slower restarts — fix later if it becomes annoying. - Verified live: recreated both dev containers,
npm installran from the workspace root correctly, bothvite devservers started and served200on :3101/:3102 withtracker-sharedimports (TS utils, asvelte/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 andtracker-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.ts→packages/tracker-shared/src/api/. Bigger blast radius than the first slice: 7 otherlib/api/*.tsfiles 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'sreports.ts) import./client/./typesby relative path and had to be repointed totracker-shared/api/client//typeseven though those files themselves aren't moving. Also needed explicit (non-wildcard)exportsentries for all four, same TS-extension-resolution reason as thetone/pagination/toastfix from the first slice. Verified:svelte-check0 errors, 46 tests pass, both builds succeed, both dev containers still serve200— all in both apps. -
State (done, 2026-07-06):
lib/state/auth.ts→packages/tracker-shared/src/state/auth.ts(already imported onlytracker-shared/api/auth//clientplussvelte/storeand$app/environment— no per-app dependency, clean move). 18 consumer files repointed. Verified: 0svelte-checkerrors, 46 tests pass, both builds succeed, both containers200. -
Phase 2b — theme
storageKeyreconciled,themes.ts/index.ts/ThemeSelector.sveltemoved (done, 2026-07-07). ParameterizedstorageKeyvia aPUBLIC_THEME_STORAGE_KEYenv var read through$env/dynamic/public(env.PUBLIC_THEME_STORAGE_KEY ?? "tracker-theme"), following the same patternclient.tsalready used forVITE_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)..envis gitignored (holds secrets likeGOOGLE_CLIENT_SECRET), so those edits are local-only and don't ship with the PR — only.env.exampleis tracked. Anyone else pulling this branch needs to copyPUBLIC_THEME_STORAGE_KEYfrom.env.exampleinto 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'sapp.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 builtindex.htmland live dev server render their own distinctstorageKey— confirms behavior is fully preserved.ThemeSelector.sveltewas then a clean move (byte-identical, only$lib/themeimport, now resolved). Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. -
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, notundefined. That doesn't triggerthemes.ts's?? "tracker-theme"fallback (dynamic env vars are onlyundefined/??-triggering through$env/dynamic/public, not the static HTML placeholder). So the inline script would have read/writtenlocalStorage[""]while the app usedlocalStorage["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.svelteis imported bySidebar.sveltevia a relative path (./AccountMenu.svelte), repointed totracker-shared/components/AccountMenu.svelte. Verified: 0svelte-checkerrors/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 containers200.
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.tsreconciled and moved (done, 2026-07-07). Both were "admin is a strict superset" cases: admin'strackers.tshad everything frontend's had plusTrackerFull,createTracker,deleteTracker,importTrackersFromCsv, ahashed_advertisement_keyfield, and abuildQuery()helper; admin'sproduction-runs.tshad the same shape plus extraListProductionRunsParamsfilters (brand_id/parent_id/active_only) and a generalizedbuildQuery(). Moved admin's version of both topackages/tracker-shared/src/api/, deleted both per-app copies. Frontend's locally-namedUpdateTrackerParamstype had zero external consumers (only used inside its owntrackers.ts), so it was dropped in favor of admin's equivalentTrackerUpdateInput— no call sites needed updating. Repointed 54 consumer files across both apps ($lib/api/trackers/$lib/api/production-runs→tracker-shared/api/...), plus one relative import (tracker-admin-svelte/lib/api/dark-trackers.tsimportedTrackerStatusvia./trackers). Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. - 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.svelte→packages/tracker-shared/src/(components undersrc/components/).PasteSelectModal.svelte's ownmatchTrackersimport repointed totracker-shared/matchTrackerssince it moved in the same batch. Test files (trackerDisplay.test.tsin both apps,campaignGrouping.test.ts/matchTrackers.test.tsin frontend only — admin has no counterparts for those two) stayed app-local since they're not identical between apps, just had their relative imports (./trackerDisplayetc.) repointed totracker-shared/.... Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. - Phase 2e, part 2 —
brands.ts+SearchDropdown.sveltereconciled and moved,CampaignEditorModal.svelteunblocked and moved too (done, 2026-07-07). Both reconciliations were trivial:brands.tsdiffered by one doc comment only;SearchDropdown.sveltediffered by admin's optionalid/disabledprops (frontend just never passed them, so adopting admin's superset is behavior-preserving). Moved admin's versions of both topackages/tracker-shared/src/api/andsrc/components/. With those plus the earliertrackers.ts/production-runs.ts/animatedDialog.sveltemoves,CampaignEditorModal.sveltehad zero remaining$lib/...imports and was byte-identical, so moved it too in the same PR. Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. -
lib/components/ThemeSelector.svelte— moved alongside thestorageKeyreconciliation 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.svelteare 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.tsandroutes/+layout.svelteare SvelteKit route files (routing is per-app, same constraint as the Phase 2a title-text pages).app.d.tsandapp.cssare different — not routing, but SvelteKit/Tailwind tooling conventions:app.d.tsmust live atsrc/app.d.tsfor SvelteKit's ambient-type system to pick it up per app, andapp.css's@sourcedirective is resolved relative to its own location — ifapp.cssmoved intopackages/tracker-shared, Tailwind's content detection would start scanning from there instead of each app's ownsrc/, 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.ts→packages/tracker-shared/src/. Each pair differed only by which side had more explanatory comments (or, forClientEditorModal.svelte, just an<h2>line-wrap style); picked whichever side was the comment superset as the source, no content lost. Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. - ~~
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 extralast_processed_atfield (inert either way, neither test asserts on it). Now byte-identical in both apps, but stays app-local, not moved: each app'svitestonly globs its ownsrc/**, not into a workspace-linked package'snode_modulessymlink, so a test file living inpackages/tracker-sharedwouldn'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 — thestorageKeypiece 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— parameterizestorageKey~~ done, see Phase 2b above - Done (2026-07-07):
lib/components/AppShell.svelte,lib/components/AuthShell.svelte,lib/components/Sidebar.svelte→packages/tracker-shared/src/components/.Sidebar.svelteneeded the most surgery since it's the one that's genuinely per-app config (nav items), not just a differing string: Sidebarnow takesappName,tagline,navigation: NavItem[]props (NavItemexported alongside the default export, same pattern asSearchDropdownOption)AppShelltakesappName,tagline,metaDescription,navigation, threading the first three +navigationdown to its two<Sidebar>instances (desktop rail + mobile drawer)AuthShelltakesappName,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 shareappName, butmetaDescriptionandtaglinediffer from each other) — caught by re-reading the original diff carefully rather than assuming a 2-prop shape- Verified concretely: grepped both apps'
+layout.sveltecall 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). 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200.
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 thetitleprop),lib/components/clients/EntityCard.svelte(frontend's superset witheditDisabledReason),lib/components/clients/EntityColumn.svelte(frontend's superset witheditDisabledReason/addDisabledReason; its relative import ofEntityCard.svelterepointed totracker-shared/...since both moved together) →packages/tracker-shared/src/components/(clients/subfolder for the latter two). Verified: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. - Done (2026-07-07):
lib/components/LoadingIndicator.svelte(admin) /lib/components/dashboard/MapLoadingIndicator.svelte(frontend) merged into oneLoadingIndicator.svelteinpackages/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 noprefixprop was needed; just standardized on admin'sapp-loadernaming and kept the existinglabelprop (every call site already passes its ownlabelexplicitly, 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 (LoadingIndicatororMapLoadingIndicator) since the import identifier is independent of the underlying file/export name. - ~~
lib/components/SearchDropdown.svelte— admin'sid/disabledprops 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 (LoadingIndicatorvsMapLoadingIndicator) for the same underlying shared file. Route files can't move topackages/tracker-sharedanyway, so left as-is — this is as reconciled as it can get. -
routes/(auth)/login/+page.svelte— backported frontend's SSO provider-error query-param handling (?error=...→PROVIDER_ERROR_MESSAGESlookup →initialErrorprop) 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 (withinitialError) topackages/tracker-shared/src/components/auth/. No$libblockers, 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 optionalproduction_run_idfilter param toListLocationsParams,listDeliveryLocations,listStorageLocations) adopted as the superset →packages/tracker-shared/src/api/locations.ts. - ~~
lib/api/production-runs.ts— admin factors query-building into abuildQuery()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) vslib/utils/campaignPermissions.ts(frontend) — cross-referencing found a bigger mess than the doc anticipated. Frontend actually had two files: its owncampaignDisplay.ts(3 functions) was a byte-identical-on-overlap subset of frontend's owncampaignPermissions.ts(9 functions) — a within-app duplication bug, not just cross-app. Admin'scampaignDisplay.ts(5 functions:campaignStatus,isParentBatch,hasEnded,addChildRunTooltip,formatCampaignDate) is a strict subset of frontend'scampaignPermissions.ts, which addscanEditCampaign/editTooltip/canDeleteCampaign/deleteTooltiprole-gating logic admin doesn't need (per admin's own comment: "every user who reaches this app is already an admin"). Moved frontend'scampaignPermissions.ts(the true superset across both apps) topackages/tracker-shared/src/campaignPermissions.ts, deleted all three original files (admin'scampaignDisplay.ts, frontend's redundantcampaignDisplay.ts, frontend's movedcampaignPermissions.ts), repointed 3 consumers + 1 test file. Verified: 0svelte-checkerrors/warnings, 46 tests pass (including the 15-testcampaignPermissions.test.tssuite now exercising the shared module), both builds succeed, both containers200.
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 vialocalStorageusing the existingloadPersistedJson/savePersistedJsonpattern (same as the pre-existingpageSizepersistence), keyed per app/page (e.g.campaign-run-tracker-sort,frontend-tracker-sort). - Done (2026-07-07):
lib/components/campaigns/RadialMoveWheel.svelte→packages/tracker-shared/src/components/. TheisOverHub"extra hover-state logic" the original audit flagged turned out to already exist identically in both files (used to deriveactiveTargetId) — frontend's template just referencedisOverHubdirectly in the parent-hub class ternary instead of re-deriving the equivalentactiveTargetId === parentTarget.id && !parentDisabledadmin 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|globaltransition requirement, the pointercancel handling), then ported admin'shandleTargetKeydownfunction and therole="button"/tabindex="0"/onkeydown/onclickattributes 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: 0svelte-checkerrors/warnings, 46 tests pass, both builds succeed, both containers200. -
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 optionalonEdit/onDeleteprops), and merged the separate fetch-status dot into the corner itself as a mutedcolor-mixgradient tint (color = fetch status, always visible to every viewer; checkmark = selection, only meaningful/interactive for manager+ roles viacanMove). 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 whendefaultProductionRunId/location.production_run_idresolves to a value) in place of the free-choice brand/campaignSearchDropdowns. Confirmed both apps'locations/+page.svelteonly ever populate the fixed-mode props from acampaign_idURL search param (a genuinely fixed context, not an interactive in-page filter — both pages already hide their filter UI in that case viashowFilters/equivalent), so frontend gains the same locked display in that scoped view with no loss of its general free-choice behavior elsewhere. Required addingleaflet/leaflet-geosearchas real dependencies ofpackages/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'spackage.json, not the consuming app's, so the rawleaflet/dist/leaflet.cssimport failed to resolve duringvite builduntil declared there;npm installpicked 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'slocations/+page.sveltehad no "copy locations from a related campaign" affordance at all, a genuine feature gap rather than duplication. Moved the component topackages/tracker-sharedand ported admin'scopySourceProductionRunsderivation (siblings sharing the same parent campaign) plus itscopyLocationshandler (paginated fetch of the source campaign's locations, then best-effort per-locationcreate*Locationcalls) onto frontend's page — frontend'sselectedProductionRunis always URL-param-scoped (no in-page filter UI), so the "copy" button'scopySourceProductionRuns.length > 0guard behaves identically to admin's. Also brought frontend's per-card Import/Export CSV buttons frombtn-outlinetobtn-primaryto 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 (isAdmincheck + "Access denied" screen); frontend has noneroutes/(app)/+page.svelte— admin dashboard (calendar heatmap, stats) vs. frontend live map/table — different features entirelyroutes/(app)/trackers/[id]/+page.svelte— admin adds full tracker CRUD; frontend is read-mostlyroutes/(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 buildfor both apps produces working, independentbuild-distoutput with no shared runtime dependency — confirmed in Phase 0 (tone.js/Pagination.jschunks 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/vitestpass in both apps after import changes — verified on literally every PR in this effort (0svelte-checkerrors, 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.