Files
manage/.pi-tmp/slice2-review.md
T
Developer a8dfbd5dc6 Cleanup: delete dead top-level pages + update docs (Slice 11)
Delete the old top-level page files whose content was migrated into
service-page tabs in slices 5-9:
- pages/Media.tsx, Applications.tsx (-> MediaTab)
- pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab)
- pages/Actions.tsx (-> ActionsTab)
- pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs)
- components/BackupsPage.tsx (-> JobsTab)
- components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs)
- hooks/useUsers.ts (orphaned after Users page deletion)
- the corresponding page test files (Media, FileBrowser, Applications,
  Actions, UsersPage) that tested the deleted pages directly.

The service-tab components are the live implementations; ServicePage
renders them. No live code references the deleted files.

Docs: append an Information Architecture section to REQUIREMENTS.md
documenting the services-as-hub model (nav shape, service-page tabs,
service type registry, Users->Authentik, Observability split, legacy
route 404s, empty state). Add a CHANGELOG entry under [Unreleased].

92 frontend tests pass (was 112; -20 deleted page tests); 271 backend
tests pass; lint/build green.

Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
2026-06-26 20:11:02 +00:00

9.0 KiB

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:

{isMobile && mobileSections.length > 0 ? (
  <MobileWidgetSections sections={mobileSections} />
) : (
  visibleWidgets.map((widget) => (
    <WidgetInstanceCard key={widget.id} widget={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:

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 <section> (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.