# Slice 2 Review — Dashboard mobile layout (mobile-responsive-parity) **Scope:** unstaged diff on `frontend/src/pages/Dashboard.tsx` (+134/-7) and `frontend/src/pages/__tests__/Dashboard.test.tsx` (+176/-7). Slice 1 (primitives: `useIsMobile`, `mobile-touch-target` CSS) is already committed. ## Verdict: **commit** No blockers. One non-blocking deviation from the task wording (JS-gated `md:hidden` instead of the Tailwind class), which is functionally equivalent and tested. All seven requested verification points pass. --- ## 1. Desktop non-regression (R7.4 / R10.1) — ✅ CONFIRMED, most important check `Dashboard.tsx:541-547` — the desktop branch is literally the original code: ```tsx {isMobile && mobileSections.length > 0 ? ( ) : ( visibleWidgets.map((widget) => ( )) )} ``` When `isMobile === false`, the renderer emits the exact same `visibleWidgets.map(...)` → `WidgetInstanceCard` sequence, with the same `visibleWidgets` memo (`filter(enabled).sort(sort_order asc)`, unchanged at `Dashboard.tsx:456-461`). No wrapper element is introduced on desktop, sort order is identical, and no new query runs on the desktop path beyond the cache-shared `useServiceInstances()` (see §6). The only desktop-visible addition is the `useIsMobile()` hook and the `mobileSections` memo, both of which are pure and render nothing extra when `isMobile` is false. Test evidence: `Dashboard.test.tsx` "does NOT render the anchor bar at desktop width" asserts the widget still renders (`getByText("Grafana Link")`) AND no section heading/pill appears (`queryByText("Observability")` is null). ## 2. Section grouping logic (`widgetSection` / `groupWidgetsBySection`) — ✅ CORRECT `Dashboard.tsx:62-78`: ```ts function widgetSection(widget, services): SectionId { if (!widget.service_id) { return widget.widget_kind === "backups" ? "backups" : "custom"; } const service = services.find((s) => s.id === widget.service_id); const serviceType = service?.service_type ?? ""; if (OBSERVABILITY_TYPES.has(serviceType)) return "observability"; // alertmanager/prometheus/grafana if (serviceType === "jellyfin") return "media"; return "custom"; } ``` Mapping verified against the closed service registry (`backend/.../integrations/registry.py`: alertmanager, grafana, jellyfin, jellyseerr, nextcloud, prometheus, ssh_tasks) and builtin widget kinds (`widgets/builtin.py`: static, backups): | Widget | Result | |-----------------------------------------------------|-----------------| | builtin `backups` (no service_id) | backups ✓ | | builtin `static` (no service_id) | custom ✓ | | grafana `link`, prometheus `metric`, alertmanager `alerts` | observability ✓ | | jellyfin `activity` | media ✓ | | ssh_tasks `task_output` | custom ✓ | | nextcloud / jellyseerr / unknown service_type | custom ✓ | | orphan widget (service_id points at deleted service → `service` undefined, serviceType `""`) | custom (safe fallback) ✓ | No widget kind falls through wrong. The closed-over `SECTION_ORDER` (`observability, media, backups, custom`) guarantees deterministic section render order independent of widget arrival order. ## 3. Anchor bar — ✅ CORRECT (one wording deviation, non-blocking) - Horizontal scroll: `-mx-1 flex gap-2 overflow-x-auto px-1 pb-1` ✓ - `scrollIntoView({ behavior: "smooth", block: "start" })` on click ✓ (`Dashboard.tsx:107-113`) - `scroll-mt-16` on each `
` (`Dashboard.tsx:124`) so the sticky TopBar (64px ≈ `mt-16`) does not cover the heading ✓ - `md:hidden`: **implemented via JS gating** (`isMobile && mobileSections.length > 0`), NOT via a Tailwind `md:hidden` class. Task 2.2 literally says "Anchor bar `md:hidden`". Functionally equivalent — at md+ `useIsMobile()` returns false so `MobileWidgetSections` is never mounted, which is cleaner than rendering hidden DOM. Tested at both breakpoints. **Non-blocking note only.** ## 4. Empty sections — ✅ CONFIRMED `groupWidgetsBySection` filters with `s.widgets.length > 0` (`Dashboard.tsx:94`). The same filtered `sections` array feeds BOTH the anchor bar pill list and the section list inside `MobileWidgetSections`, so an empty section appears in neither. Test evidence: with observability/media/backups widgets present and no custom widget, `queryByText("Custom")` is null (`Dashboard.test.tsx` "renders widgets in a single column…"). ## 5. Test quality — ✅ GOOD Three new tests, all asserting behavior (not snapshots): 1. "renders widgets in a single column with an anchor bar below md" — checks each populated section label is present, the empty `Custom` section is absent, and every widget title renders. 2. "does NOT render the anchor bar at desktop width" — asserts widget renders AND no section heading appears (anchor-bar-absent + widgets-present). ✓ 3. "anchor bar pills jump to their section via scrollIntoView" — spies on `Element.prototype.scrollIntoView`, clicks the Media pill via `getByRole("button", { name: "Media" })`, asserts the spy fired. ✓ `matchMedia` mock (`Dashboard.test.tsx:79-92`) is correct and complete: it returns `{ matches, media, onchange, addEventListener, removeEventListener, addListener, removeListener, dispatchEvent }`. `matches` is keyed on the exact query string `"(max-width: 768px)"` that `useIsMobile` uses, so the boolean flips correctly. `useIsMobile` only needs `addEventListener`/`removeEventListener` - the initial `matches` read, all of which are stubbed. The mock is reset in `beforeEach` via `setMatchMedia(false)`. Minor note: the widget-stub was upgraded to render `widget.title` (`Dashboard.test.tsx:6-9`) so tests can distinguish widgets — good improvement, doesn't affect the existing shortcut-CRUD tests. ## 6. `useServiceInstances()` addition — ✅ CACHE-SHARED, no duplicate request `useServiceInstances(serviceType?)` builds queryKey `["services", "instances", serviceType ?? "all"]` (`useServices.ts:21`). The Dashboard calls it with no arg → key `["services", "instances", "all"]`. Critically, **`WidgetInstanceCard` already calls `useServiceInstances()` with no arg** (`WidgetInstance.tsx:12`) for every rendered widget, as does `WidgetConfigDialog` (`WidgetConfigDialog.tsx:167`). So the Dashboard's new call hits the exact same TanStack cache entry that is already being subscribed to by the widget cards it renders. TanStack Query deduplicates by key → **zero additional network requests** introduced by this change on either desktop or mobile. The 60s `refetchInterval` is shared. ## 7. Sort order within sections (R7.3) — ✅ PRESERVED `visibleWidgets` is sorted by `sort_order` ascending (`Dashboard.tsx:456-461`, unchanged). `groupWidgetsBySection` iterates `visibleWidgets` in order and `.push()`es into per-section arrays, preserving insertion order. Therefore within each section the user's configured sort order is intact, and sections themselves render in fixed `SECTION_ORDER`. R7.3 satisfied. --- ## Build / lint / test evidence | Command | Result | |---------|--------| | `npm run lint` | ✅ 0 errors (2 pre-existing warnings in `UsersPage.impl.tsx`, unrelated) | | `npm run build` (`tsc -b && vite build`) | ✅ built, typecheck clean | | `npm run test` (vitest run) | ✅ 25 files / 89 tests passed | | `vitest run Dashboard.test.tsx` | ✅ 6 tests passed (3 original + 3 new) | ## Other observations (non-blocking) - The mobile single-column container is `grid grid-cols-1 gap-4` (`Dashboard.tsx:120`). The pre-change desktop widgets were already a flat vertical stack (no grid wrapper), so mobile parity is effectively the same column plus grouping — consistent with R7.1. - `mobileSections` is recomputed via `useMemo` keyed on `[visibleWidgets, services]`; correct deps, no stale-closure risk. - `OBSERVABILITY_TYPES`, `SECTION_ORDER`, `SECTION_META` are module-level constants — no per-render allocation. Good. - Diff is +303/-7 across 2 files, well under the 400-line review budget and exactly the two files Slice 2 scoped. ## Blockers None. ## Suggestions (non-blocking, do not gate commit) 1. If strict adherence to the task wording "Anchor bar `md:hidden`" is preferred, add the `md:hidden` Tailwind class to the anchor bar div and render `MobileWidgetSections` unconditionally inside the mobile branch. The current JS-gated approach is equally correct and avoids mounting hidden nodes; leaving as-is is fine. 2. Consider an explicit test that an orphan widget (service_id set but not in `services`) lands in `custom` — the code handles it but no test pins the fallback. Low value; optional. ## Acceptance All seven requested verification points pass. Desktop non-regression is verified by code identity on the `isMobile === false` branch and by the desktop test. No scope widening (only the two Slice-2 files changed, no backend, no other pages). No staged files. Ready to commit.