Svelte Dashboard Refactor TODO
Follow-up from a compliance check against
frontend-svelte-implementation-plan.md.
Svelte 5 syntax is fully compliant across the app (no export let, $:,
or on: directives anywhere in tracker-frontend-svelte/src), but
src/routes/(app)/+page.svelte has grown to ~815 lines while building out
brand/campaign/status filtering, the map, and the tracker table — exactly
the "huge monolithic page file" the plan warns against. This is the
extraction pass to bring it back in line before adding more features on
top of it.
Extractions
-
SearchDropdown/AutocompleteInputcomponent (lib/components/) The Brand and Campaign search inputs are near-identical ~40-line blocks (text input + clear button + dropdown list + blur-close delay) copy-pasted twice in+page.svelte. Collapse into one reusable component parameterized by label/placeholder/options/ selected value/on-select callback. Done:src/lib/components/SearchDropdown.svelte, used by both the Brand and Campaign filters.+page.sveltedropped from 815 to 743 lines; the dropdown open/close state now lives inside the component instead of the page. -
StatusBadgecomponent (lib/components/) ThetoneClasses[...]badge styling is duplicated between the Status filter badges and the table's status cell. One component taking a status + tone should cover both. Done:src/lib/components/StatusBadge.svelte, with avariantprop (filtervsstatus) to preserve the two call sites' slightly different sizing without duplicating the tone-color lookup. Also extracted the tone→class mapping itself intosrc/lib/utils/tone.ts, since it was duplicated a third time by the stat cards — bothStatusBadgeand the stat cards now import the sametoneClass()helper. -
StatCardcomponent (lib/components/dashboard/) The four stat tiles (Total/In Transit/Delivered/In Storage) are near-identical markup driven by thestatsarray — extract into a small presentational component. Done:src/lib/components/dashboard/StatCard.svelte, using the sametoneClass()util asStatusBadge. -
Table + pagination primitives (
lib/components/table/or similar) Per the plan's UI Migration Order (item 6, "table and pagination primitives" should exist before dashboard pages), pull the sortable table header, row rendering, and pagination footer out of the page into reusable pieces other list pages (locations, campaigns) can share later. Done, split by genericity rather than one big table component: -lib/components/SortableHeaderButton.svelteandlib/components/Pagination.svelte— fully generic, no tracker knowledge, reusable by any future list page. -lib/components/dashboard/TrackerTable.svelte— the tracker-specific row rendering (still dashboard-scoped, since a fully generic column-config table would have been premature abstraction for one current use case). -lib/utils/trackerDisplay.ts—statusTone/getLocationDisplay/formatTimestamp, pure functions moved out of the page since they only operate on aTracker, no page-state dependency.+page.sveltedropped from 815 to 578 lines. Verified sorting (asc/desc, case-insensitive), pagination (page nav + page-size change), and inline editing all still work end-to-end through the new component boundaries. -
EditableNameCellcomponent The inline name-editing state (editingTrackerId,draftName,isSavingName,saveNameError) and markup currently live at the page level. Encapsulate as its own component so the table row stays readable. Done:src/lib/components/dashboard/EditableNameCell.svelte. KepteditingTrackerId/draftName/etc. owned by the page (not moved into the component) since that single shared state is what enforces "only one row editable at a time" across siblings — moving it into per-row local state would have silently allowed multiple rows to edit simultaneously. Verified save/cancel/revert end-to-end against the real API. -
Move filter persistence out of the page
loadPersistedFilters/savePersistedFilters(brand, campaign, statuses, search term, items-per-page) are inline in+page.svelte. Mirror the existing$lib/state/auth.tspattern — a$lib/state/dashboardFilters.ts(or similar) module. Done:src/lib/utils/dashboardFilters.ts— usedutils/rather thanstate/since, unlike auth, this isn't a shared reactive store read from multiple places, just pure localStorage read/write functions backing page-local$state.pageSizeOptionsmoved with it since the persistence layer validates against it. Verified persisted filters still restore correctly across a real page reload. -
Consider extracting tracker data-fetching — decided against The brand/campaign ID resolution + fetch effect + sort/paginate derivations could move into a composable/module so
+page.svelteends up closer to a pure "page container" wiring presentational pieces together, per the plan's suggested responsibility split (page container / data loading / state & derived values / presentational components / feature-specific controls / API adapter). Decision: left in the page. Unlike the pieces already extracted, this logic isn't duplicated anywhere else — it's page-specific orchestration (resolve filters → fetch → sort → paginate) with no second call site to justify a shared module. Moving it would be extraction for its own sake, not a DRY fix. Revisit if a second page ever needs the same tracker-fetching/sorting/pagination pipeline.
Verification
- Re-check every behavior this refactor touches still works: brand/ campaign linking, status badge toggling + dropdown sync, sorting, pagination, filter persistence across reloads, map markers/popups, and inline name editing. This is a structural refactor, not a feature change — no behavior should shift. Done: verified individually after each extraction throughout, plus one final consolidated pass exercising all of the above end-to-end in a single session. No regressions found.
Reference
- Already a good example of the target shape:
TrackerMap.svelte— small, self-contained, direct Leaflet integration via lifecycle hooks, kept separate from the page container.
Campaign Management Refactor
Follow-up compliance pass, same rules as above. The Campaign Management
feature (src/routes/(app)/campaign-management/+page.svelte and its
[id]/+page.svelte detail route) was built across a single fast-moving
session and never held to the same bar as the dashboard refactor above.
It drifted into exactly the "huge monolithic page file" pattern this
document exists to catch: the list page reached 793 lines, the detail
page 561, each mixing data loading, permission logic, a drag-and-drop
state machine, and duplicated markup in one file.
List page extractions
-
persistedJson.tsutility (lib/utils/) The list page'sloadPersistedFilters/savePersistedFiltersis the same try/catch-JSON-localStorage shape already duplicated bydashboardFilters.tsand inline inreports/+page.svelteandlocations/+page.svelte. Generalize intoloadPersistedJson<T>(key, defaultValue, parse)/savePersistedJson<T>(key, value), deliberately as plain functions (not a runes composable) — the page keeps owning its own$stateand persistence$effect, this just removes the duplicated parse/validate/try-catch boilerplate around it. Done: added as plain functions exactly as scoped; the list page'sparsePersistedFiltersnow owns validation and callsloadPersistedJson/savePersistedJsonaround it. -
campaignPermissions.tsmodule (lib/utils/)campaignStatus,isParentBatch,hasEnded,canEditCampaign,editTooltip,canDeleteCampaign,deleteTooltip,addChildRunTooltip,formatCampaignDatecurrently live as page-level functions threaded through nine call sites in the markup. Extract as pure functions taking aCampaignRoles = { isAdmin, isManager }param instead of reading the auth store directly, mirroringlib/utils/matchTrackers.ts+ its colocated test. Done: extracted verbatim with aCampaignRolesparam; the page now derives oneconst roles = $derived({ isAdmin, isManager })and threads it into every call site. 15 colocated tests added (campaignPermissions.test.ts). -
campaignGrouping.tsmodule (lib/utils/)groupCampaigns(campaigns, brandFilter)— the parent/child grouping + sort + brand-filter derivation currently inline as the page'scampaignGroups$derived.by. Done: extracted with 5 colocated tests (empty input, nesting + sort, children excluded from the top-level list, brand filtering, empty-children default). -
CampaignRow.sveltecomponent (lib/components/campaigns/) The parent-row and child-row<tr>blocks are ~90 lines of near-duplicate markup (same 7 columns, same action buttons, just different indent/background and a+Childbutton). Collapse into one row component with avariant: "parent" | "child"prop, following the callback-per-action convention already established byPagination.svelte/TrackerTable.svelte. Done: built with the plannedvariantprop andonOpen/onAddChild/onEdit/onDeletecallbacks; the list page's<tbody>now renders two<CampaignRow>per group instead of duplicated markup. -
CampaignDeleteModal.sveltecomponent (lib/components/campaigns/) The delete-confirmation dialog is hand-rolled inline (modal modal-openclass toggling) even though this exact feature already has three proper modal components (CampaignEditorModal,MoveTrackerModal,PasteSelectModal). Replace with a real component using the samebind:this+showModal()/close()lifecycle asMoveTrackerModal.svelte. Done: built with thebind:this+showModal()/close()lifecycle; confirmed live via Playwright that ESC-to-close works and the confirm/error flow is unchanged.
Detail page extractions
-
pointerDrag.svelte.tscomposable (lib/utils/) ThependingDrag/dragStatepointer-threshold state machine (windowpointermove/pointerup/pointercancellisteners measuring distance against a 6px threshold to distinguish click from drag-start) has nothing campaign-specific about it. Extract ascreateDragThreshold<T>({ thresholdPx }). This is the first.svelte.tsrunes-composable file in the repo (everything reactive so far lives in.sveltefiles or classicwritablestores) — return a plain object with getters, never aclasswith public fields, since destructuring a class instance's$statefields snapshots the value instead of staying reactive. Done: built exactly as scoped, generic over payloadT(used asnumber[]tracker IDs); exposesdragState/isActive/start/cancel/consumeDrop. Verified live via Playwright with real pointer drags (threshold-gated drag-start, drop-on-hub, drop-on-spoke, drop-to-cancel) before building anything on top of it, per the plan's risk-mitigation sequencing. -
moveWheelTargets.tsmodule (lib/utils/)isRunEnded,buildWheelTargets(parent-hub + child-spoke computation, the ended-run filtering, the 12-item cap,isAtParentRun/canOpenWheel),buildMoveTargets,targetRunName— currently five separate$derivedblocks plus two helper functions in the page. Done: extracted with 12 colocated tests, including the four cases called out below (empty children, all-ended children, the 12-cap, disabled-hub-at-parent). -
trackerSelection.svelte.tscomposable (lib/utils/)createTrackerSelection(trackers: () => Tracker[])forselectedIds/selectedTrackers/allSelected/toggle/toggleAll/replace/clear. Takes the live tracker list as a getter function, not a plain array — the correct Svelte 5 idiom for passing reactive state into a composable without it going stale. Done: built as scoped (plus ahas(id)getter used by the template/row components). Verified live via Playwright: individual toggle, select-all/clear, card-view checkbox, paste-to-select (replace), and move-dialog ESC-cancel all preserve/update selection correctly. -
TrackerCard.svelte/TrackerListRow.sveltecomponents (lib/components/campaigns/) The card view duplicates its entire tracker display (name, MAC, status badge) between acanMovebranch (with checkbox) and a read-only branch (without) — same duplication again in the table<tr>. One component per view mode, withonPointerDown/onToggleas optional callback props for the read-only case. Done: built withonToggle/onDragStartcallback props (matching the established callback-per-interaction convention); the page's card grid and table body now each render one component call per tracker instead of duplicated markup. -
Considered and rejected: extracting move orchestration
moveTrackers/handleMove/handleRadialDrop/dragLabelcall the API, callshowToast, and mutate page state (trackers,selectedIds) — page-level controller functions, not reusable logic, in the same waysaveCampaignis on the list page. Extracting them would mean passing 4+ callbacks into a module for no real simplification. Left in the page, deliberately. Done: confirmed as still the right call — these stayed in the page exactly as scoped.
Verification
- Re-check every behavior this refactor touches: list pagination +
page-size persistence, brand filter, create/edit/delete
permissions and tooltips (including the new delete modal's
ESC-to-close and backdrop-click-to-close — a genuine small
behavior change from class-toggling to native
<dialog>semantics, worth confirming explicitly), drag-to-move via mouse (hub, spoke, cancel), select-all, paste-to-select, move-via- dialog, and toast messages naming the destination run. Structural refactor only — no behavior should shift; any diff is a bug. Done: full Playwright pass against the real dev server confirmed every item — list create/edit/delete, brand filter, page-size persistence, delete-modal ESC-to-close, mouse drag-to-move to both hub and spoke, drag-to-cancel, select-all/clear, paste-to-select, move-via-dialog with ESC-cancel, and view-mode persistence across reloads. No behavior regressions found; all test data cleaned up afterward via the UI itself (create/delete round-trip). - New colocated
.test.tsfiles forcampaignPermissions.ts,campaignGrouping.ts, andmoveWheelTargets.ts, covering at minimum: empty children, all-ended children, count exceeding the 12-cap, and the disabled-hub-at-parent case. Done: 32 new tests across the three files (15 + 5 + 12), all passing alongside the pre-existingmatchTrackers.test.ts(38 total).svelte-checkclean throughout (0 errors/warnings after every extraction).