Skip to content

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 / AutocompleteInput component (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.svelte dropped from 815 to 743 lines; the dropdown open/close state now lives inside the component instead of the page.

  • StatusBadge component (lib/components/) The toneClasses[...] 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 a variant prop (filter vs status) to preserve the two call sites' slightly different sizing without duplicating the tone-color lookup. Also extracted the tone→class mapping itself into src/lib/utils/tone.ts, since it was duplicated a third time by the stat cards — both StatusBadge and the stat cards now import the same toneClass() helper.

  • StatCard component (lib/components/dashboard/) The four stat tiles (Total/In Transit/Delivered/In Storage) are near-identical markup driven by the stats array — extract into a small presentational component. Done: src/lib/components/dashboard/StatCard.svelte, using the same toneClass() util as StatusBadge.

  • 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.svelte and lib/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.tsstatusTone/getLocationDisplay/ formatTimestamp, pure functions moved out of the page since they only operate on a Tracker, no page-state dependency. +page.svelte dropped 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.

  • EditableNameCell component 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. Kept editingTrackerId/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.ts pattern — a $lib/state/dashboardFilters.ts (or similar) module. Done: src/lib/utils/dashboardFilters.ts — used utils/ rather than state/ since, unlike auth, this isn't a shared reactive store read from multiple places, just pure localStorage read/write functions backing page-local $state. pageSizeOptions moved 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.svelte ends 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.ts utility (lib/utils/) The list page's loadPersistedFilters/savePersistedFilters is the same try/catch-JSON-localStorage shape already duplicated by dashboardFilters.ts and inline in reports/+page.svelte and locations/+page.svelte. Generalize into loadPersistedJson<T>(key, defaultValue, parse) / savePersistedJson<T>(key, value), deliberately as plain functions (not a runes composable) — the page keeps owning its own $state and 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's parsePersistedFilters now owns validation and calls loadPersistedJson/savePersistedJson around it.

  • campaignPermissions.ts module (lib/utils/) campaignStatus, isParentBatch, hasEnded, canEditCampaign, editTooltip, canDeleteCampaign, deleteTooltip, addChildRunTooltip, formatCampaignDate currently live as page-level functions threaded through nine call sites in the markup. Extract as pure functions taking a CampaignRoles = { isAdmin, isManager } param instead of reading the auth store directly, mirroring lib/utils/matchTrackers.ts + its colocated test. Done: extracted verbatim with a CampaignRoles param; the page now derives one const roles = $derived({ isAdmin, isManager }) and threads it into every call site. 15 colocated tests added (campaignPermissions.test.ts).

  • campaignGrouping.ts module (lib/utils/) groupCampaigns(campaigns, brandFilter) — the parent/child grouping + sort + brand-filter derivation currently inline as the page's campaignGroups $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.svelte component (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 +Child button). Collapse into one row component with a variant: "parent" | "child" prop, following the callback-per-action convention already established by Pagination.svelte/TrackerTable.svelte. Done: built with the planned variant prop and onOpen/onAddChild/onEdit/onDelete callbacks; the list page's <tbody> now renders two <CampaignRow> per group instead of duplicated markup.

  • CampaignDeleteModal.svelte component (lib/components/campaigns/) The delete-confirmation dialog is hand-rolled inline (modal modal-open class toggling) even though this exact feature already has three proper modal components (CampaignEditorModal, MoveTrackerModal, PasteSelectModal). Replace with a real component using the same bind:this + showModal()/close() lifecycle as MoveTrackerModal.svelte. Done: built with the bind: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.ts composable (lib/utils/) The pendingDrag/dragState pointer-threshold state machine (window pointermove/pointerup/pointercancel listeners measuring distance against a 6px threshold to distinguish click from drag-start) has nothing campaign-specific about it. Extract as createDragThreshold<T>({ thresholdPx }). This is the first .svelte.ts runes-composable file in the repo (everything reactive so far lives in .svelte files or classic writable stores) — return a plain object with getters, never a class with public fields, since destructuring a class instance's $state fields snapshots the value instead of staying reactive. Done: built exactly as scoped, generic over payload T (used as number[] tracker IDs); exposes dragState/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.ts module (lib/utils/) isRunEnded, buildWheelTargets (parent-hub + child-spoke computation, the ended-run filtering, the 12-item cap, isAtParentRun/canOpenWheel), buildMoveTargets, targetRunName — currently five separate $derived blocks 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.ts composable (lib/utils/) createTrackerSelection(trackers: () => Tracker[]) for selectedIds/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 a has(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.svelte components (lib/components/campaigns/) The card view duplicates its entire tracker display (name, MAC, status badge) between a canMove branch (with checkbox) and a read-only branch (without) — same duplication again in the table <tr>. One component per view mode, with onPointerDown/ onToggle as optional callback props for the read-only case. Done: built with onToggle/onDragStart callback 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/dragLabel call the API, call showToast, and mutate page state (trackers, selectedIds) — page-level controller functions, not reusable logic, in the same way saveCampaign is 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.ts files for campaignPermissions.ts, campaignGrouping.ts, and moveWheelTargets.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-existing matchTrackers.test.ts (38 total). svelte-check clean throughout (0 errors/warnings after every extraction).