From e5e444b36dc6b09c9fdc38657350b569be7c29de Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 16:31:56 -0400 Subject: [PATCH] docs(03): recover plan files and ROADMAP.md after planner worktree-base bug The planner agent's commit (d597415) was created from a worktree branch based on master rather than the current feature branch HEAD. The soft-reset safety check staged a deletion of ROADMAP.md (which exists in the feature branch but not master), and that deletion was bundled into the agent's commit. The plan files themselves were never added to the index. Recovery: stage the on-disk ROADMAP.md (with the planner's Phase 3 updates) plus the two new PLAN.md files, and commit them cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/ROADMAP.md | 157 +++++ .../phases/03-dashboard-restyle/03-01-PLAN.md | 578 ++++++++++++++++++ .../phases/03-dashboard-restyle/03-02-PLAN.md | 426 +++++++++++++ 3 files changed, 1161 insertions(+) create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/phases/03-dashboard-restyle/03-01-PLAN.md create mode 100644 .planning/phases/03-dashboard-restyle/03-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..0f3c81a --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,157 @@ +# Roadmap: Pulse Mobile Shell Redesign + +## Overview + +Eight phases mirror the deliberate build order in the source spec +(`docs/superpowers/specs/2026-05-03-mobile-shell-design.md` §8). Each phase +ships independently to `master` — no big-bang merge. Phase 1 lays PWA +metadata and safe-area utilities. Phase 2 rebuilds `app/mobile/layout.tsx` +with the new header, 5-cell bottom nav, and More drawer (deleting +`/mobile/nav` in the same change). Once the shell lands, Phases 3–7 are +independent restyles/new pages and may be executed in parallel; Phase 8 +follows Phase 7 because the user profile is reached from the Engagement +overview. All work happens in place under `/mobile/*` — no `/mobile-v2`, +no parallel routes. + +## Phases + +**Phase Numbering:** +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +Decimal phases appear between their surrounding integers in numeric order. + +- [ ] **Phase 1: PWA Scaffolding** — Manifest, viewport meta, and safe-area utilities so the shell installs and paints under the home indicator +- [ ] **Phase 2: Mobile Shell + More Drawer** — New `app/mobile/layout.tsx` (header + 5-cell bottom nav) and Sheet drawer that replaces `/mobile/nav` +- [ ] **Phase 3: Dashboard Restyle** — 2×2 KPI grid, Needs Attention strip, worker/backup status row (no charts) +- [ ] **Phase 4: Tickets Restyle** — Collapsible URL-synced filters, priority-bar rows, cursor-based infinite scroll, detail header reskin +- [ ] **Phase 5: Finance Restyle** — Adopt new Card + typography scale, swap wide tables for stacked lists +- [ ] **Phase 6: Analyzer Feed (NEW)** — `/mobile/analyzer` read-only stream + `/api/mobile/analyzer/feed` +- [ ] **Phase 7: Engagement Overview (NEW)** — `/mobile/engagement` phone-first overview reachable from the More drawer +- [ ] **Phase 8: Engagement User Profile (NEW)** — `/mobile/engagement/[userId]` real-page profile that replaces the desktop modal pattern + +## Phase Details + +### Phase 1: PWA Scaffolding +**Goal**: A manager who taps "Add to Home Screen" gets a standalone Pulse icon that opens to the mobile shell with content respecting the device safe areas. +**Depends on**: Nothing (first phase) +**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04 +**Success Criteria** (what must be TRUE): + 1. Visiting `/manifest.json` returns valid JSON with `name: "Pulse"`, `display: "standalone"`, `start_url: "/mobile"`, and theme/background colors matching the app shells + 2. The root `app/layout.tsx` references the manifest via `` and the viewport meta includes `viewport-fit=cover` + 3. A safe-area utility (Tailwind arbitrary values or shared class) is available so any sticky top/bottom bar can opt into `env(safe-area-inset-top)` / `env(safe-area-inset-bottom)` padding + 4. Installing Pulse to a phone home screen launches a chromeless app pointed at `/mobile` (no service worker, no offline) +**Plans**: 2 plans +- [x] 01-01-PLAN.md — Web App Manifest + viewport-fit=cover (PWA-01, PWA-02, PWA-03) +- [x] 01-02-PLAN.md — Safe-area `pt-safe` / `pb-safe` @utility blocks in brand.css (PWA-04, gap closure) +**UI hint**: no + +### Phase 2: Mobile Shell + More Drawer +**Goal**: Every `/mobile/*` page renders inside a new layout — sticky header (Wulf mark + Bell placeholder + avatar), scrollable content, and a 5-cell bottom nav whose fifth control opens a Sheet drawer that fully replaces `/mobile/nav`. +**Depends on**: Phase 1 +**Requirements**: SHELL-01, SHELL-02, SHELL-03, SHELL-04, SHELL-05, SHELL-06, NAV-01, NAV-02, NAV-03, DRAWER-01, DRAWER-02, DRAWER-03, DRAWER-04, DRAWER-05, DRAWER-06 +**Success Criteria** (what must be TRUE): + 1. On any `/mobile/*` route the user sees a sticky header with the Wulf wordmark linking to `/mobile/dashboard`, a Bell icon button (keyboard-focusable, no menu), and a compact avatar — no page title in the header + 2. A fixed bottom bar exposes four primary tabs (Dashboard, Tickets, Finance, Analyzer) plus a More cell; tapping a tab routes to its page and the active tab uses `text-primary` based on `pathname.startsWith(href)` + 3. Tapping More (or the header avatar) opens a single Sheet drawer with three sections — Mobile sections (Engagement), Full site (Quotes, Configuration Items, Backup Status, Ticket Digest, Admin/Sync — each with an `ExternalLink` hint), and Account (current user read-only + Sign out) + 4. Tapping Sign out in the drawer signs the user out and lands them on `/auth/sign-in` + 5. `app/mobile/nav/page.tsx` no longer exists; visiting `/mobile/nav` does not render the old standalone nav page + 6. Page content scrolls under the sticky header and is not hidden behind the bottom nav (bottom padding accounts for nav height + safe-area inset) +**Plans**: 2 plans +- [x] 02-01-PLAN.md — Build mobile shell components (HeaderBar, BottomNav, MoreDrawer) + analyzer placeholder (SHELL-02..04, SHELL-06, NAV-01..03, DRAWER-01..05) +- [x] 02-02-PLAN.md — Wire new components into app/mobile/layout.tsx, delete app/mobile/nav/page.tsx (SHELL-01, SHELL-05, DRAWER-06) +**UI hint**: yes + +### Phase 3: Dashboard Restyle +**Goal**: A manager opening `/mobile/dashboard` sees the state of the business at a glance — four KPIs, items needing attention, and a worker/backup status row — with no charts. +**Depends on**: Phase 2 +**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04 +**Success Criteria** (what must be TRUE): + 1. Dashboard renders a 2×2 grid of four primary KPI cards drawn from desktop hero stats (no 1×4 row, no charts) + 2. Below the grid, a "Needs Attention" horizontally-scrollable strip surfaces overdue tickets, failed backups, and stalled workflows; tapping a card opens its detail view + 3. A compact status row shows analyzer worker, RMM worker, and backup-success-rate; tapping any element opens the corresponding desktop admin page + 4. The page contains no recharts/chart components on phone widths +**Plans**: 2 plans +- [ ] 03-01-PLAN.md — /api/mobile/dashboard reshape + KpiCardMobile/NeedsAttentionStrip/WorkerStatusRow components (DASH-01, DASH-02, DASH-03) +- [ ] 03-02-PLAN.md — Replace /mobile/dashboard page body with 3-section layout, no charts (DASH-01, DASH-02, DASH-03, DASH-04) +**UI hint**: yes + +### Phase 4: Tickets Restyle +**Goal**: A manager triages tickets on a phone with a collapsible filter bar that deep-links via URL, priority-coloured rows, and infinite scroll — and the detail page header matches the new shell. +**Depends on**: Phase 2 +**Requirements**: TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06, TICK-07 +**Success Criteria** (what must be TRUE): + 1. The Tickets page opens with the filter strip collapsed; expanding it reveals status, priority, queue, and an assigned-to-me toggle, and changing any filter updates the URL query string (deep link works on reload) + 2. Each list row has a left-edge stripe matching priority (Critical/High/Medium/Low → red/orange/amber/slate) and shows ticket #, title, company, age, and assignee + 3. Single-tapping a row navigates to `/mobile/tickets/[id]` + 4. Scrolling to the bottom of the list automatically loads the next ~25 rows (no Next button); a "Load more" fallback button is also visible/focusable for accessibility + 5. The detail page header uses the new shell styling (Wulf mark, breadcrumb back) while the body remains largely unchanged +**Plans**: TBD +**UI hint**: yes + +### Phase 5: Finance Restyle +**Goal**: A manager reading AR / invoice / payment status on a phone sees properly spaced cards and stacked lists instead of squished wide tables — same data, new shell. +**Depends on**: Phase 2 +**Requirements**: FIN-01, FIN-02 +**Success Criteria** (what must be TRUE): + 1. `/mobile/finance` adopts the new Card and typography scale — no horizontal overflow, spacing legible on small phones + 2. Sections that previously rendered wide tables on phone widths now render as stacked lists (no new sections, no new data sources) +**Plans**: TBD +**UI hint**: yes + +### Phase 6: Analyzer Feed (NEW) +**Goal**: A manager taps the Analyzer tab and skims a most-recent-first stream of AI ticket analyses, opening any one to a phone-friendly summary view that links out to desktop for full details. +**Depends on**: Phase 2 +**Requirements**: ANL-01, ANL-02, ANL-03, ANL-04, ANL-05, ANL-06 +**Success Criteria** (what must be TRUE): + 1. Tapping the Analyzer tab in the bottom nav lands on `/mobile/analyzer` and shows a most-recent-first list of AI ticket analyses + 2. Each row shows ticket #, title, the analyzer's one-line summary, a confidence badge, and a stage indicator (Triage → Analyze → Deep Review) + 3. Tapping a row opens a mobile summary view rendering Summary, Next Step, and Next Step Rationale, with a "View full analysis" link out to the desktop analyzer page + 4. The mobile feed never exposes editing, re-run, or prompt-tuning controls (read-only by design) + 5. The list reads from `analyzer_analyses` via `/api/mobile/analyzer/feed` (or a reused list endpoint that already returns the right shape) +**Plans**: TBD +**UI hint**: yes + +### Phase 7: Engagement Overview (NEW) +**Goal**: A manager reaches Engagement from the More drawer and sees a phone-first overview — period chips, stacked summary cards, a sortable per-employee list, and one compact sparkline. +**Depends on**: Phase 2 +**Requirements**: ENG-01, ENG-02, ENG-03, ENG-04, ENG-05, ENG-09 +**Success Criteria** (what must be TRUE): + 1. The Mobile sections row in the More drawer links to `/mobile/engagement`; the Analyzer is on the bottom bar but Engagement is not + 2. The overview page shows a period selector (today / 7d / 30d) sticky just below the H1, with active period clearly indicated + 3. Summary cards (active users, total Graph hours, total Autotask hours, hours-per-active-user) render single-column stacked — no 4-up grid on phone widths + 4. The per-employee list renders as stacked rows (avatar/initials, name, role, hours bar) with a search input and a sort control above (sort by hours, name, utilization) + 5. A single compact "hours trend" sparkline renders at the top of the list, scoped to the selected period — no multi-series chart +**Plans**: TBD +**UI hint**: yes + +### Phase 8: Engagement User Profile (NEW) +**Goal**: From the Engagement overview, a manager taps an employee row and arrives at a real, shareable profile page — single-column phone-first — and the device back gesture returns them to the overview. +**Depends on**: Phase 7 +**Requirements**: ENG-06, ENG-07, ENG-08 +**Success Criteria** (what must be TRUE): + 1. Tapping a row in the per-employee list navigates to `/mobile/engagement/[userId]` (segment form, shareable URL) + 2. The profile is a real page (not a modal) — the device/browser back gesture returns to the overview at the same scroll position + 3. The profile renders single-column: identity header → period selector → key metrics (compact) → activity breakdown list → recent items, sourced from the existing engagement profile data endpoints (no new data) +**Plans**: TBD +**UI hint**: yes + +## Progress + +**Execution Order:** +Phases execute in numeric order. Phase 2 unblocks Phases 3–7 (any order, parallelizable). Phase 8 follows Phase 7. + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. PWA Scaffolding | 1/2 | Executing | - | +| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - | +| 3. Dashboard Restyle | 0/2 | Not started | - | +| 4. Tickets Restyle | 0/TBD | Not started | - | +| 5. Finance Restyle | 0/TBD | Not started | - | +| 6. Analyzer Feed | 0/TBD | Not started | - | +| 7. Engagement Overview | 0/TBD | Not started | - | +| 8. Engagement User Profile | 0/TBD | Not started | - | + +--- +*Roadmap created: 2026-05-03* +*Source spec: `docs/superpowers/specs/2026-05-03-mobile-shell-design.md`* diff --git a/.planning/phases/03-dashboard-restyle/03-01-PLAN.md b/.planning/phases/03-dashboard-restyle/03-01-PLAN.md new file mode 100644 index 0000000..80cfccc --- /dev/null +++ b/.planning/phases/03-dashboard-restyle/03-01-PLAN.md @@ -0,0 +1,578 @@ +--- +phase: 03-dashboard-restyle +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - app/api/mobile/dashboard/route.ts + - components/mobile/KpiCardMobile.tsx + - components/mobile/NeedsAttentionStrip.tsx + - components/mobile/WorkerStatusRow.tsx +autonomous: true +requirements: + - DASH-01 + - DASH-02 + - DASH-03 +objective: | + Reshape the /api/mobile/dashboard response and ship three presentational + components (KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow) so plan + 02 can wire them into the page body without exploring the codebase. + +must_haves: + truths: + - "GET /api/mobile/dashboard returns kpis (4 entries), needsAttention (3 entries), and workers (3 entries) in a single round-trip" + - "KpiCardMobile renders a phone-sized KPI card with label, value, optional caption — no chart, no recharts import" + - "NeedsAttentionStrip renders a horizontal-scrolling strip of compact attention cards, each linking to a destination URL" + - "WorkerStatusRow renders a 3-cell status row (Analyzer worker, RMM worker, backup success rate) with status indicators that link to desktop admin pages" + artifacts: + - path: "app/api/mobile/dashboard/route.ts" + provides: "Single GET endpoint shaped for the new mobile dashboard sections" + contains: "kpis: [], needsAttention: [], workers: [" + - path: "components/mobile/KpiCardMobile.tsx" + provides: "Reusable phone-sized KPI card component" + exports: ["KpiCardMobile"] + - path: "components/mobile/NeedsAttentionStrip.tsx" + provides: "Horizontal-scroll strip rendering NeedsAttention cards" + exports: ["NeedsAttentionStrip", "NeedsAttentionItem"] + - path: "components/mobile/WorkerStatusRow.tsx" + provides: "Compact 3-cell worker/backup status row" + exports: ["WorkerStatusRow", "WorkerStatusEntry"] + key_links: + - from: "components/mobile/NeedsAttentionStrip.tsx" + to: "next/link" + via: "Link href={item.href}" + pattern: "from 'next/link'" + - from: "components/mobile/WorkerStatusRow.tsx" + to: "next/link" + via: "Link href={entry.href}" + pattern: "from 'next/link'" + - from: "app/api/mobile/dashboard/route.ts" + to: "postgresClient" + via: "single Promise.all of parameterised queries" + pattern: "postgresClient\\.query" +--- + + +Reshape `/api/mobile/dashboard` to return the three sections the new mobile +dashboard layout needs (4 KPIs, Needs Attention strip items, worker/backup +status entries) and ship the three presentational components plan 02 will +import. After this plan, plan 02 can replace the page body in pure UI work +without re-exploring the codebase. + +Purpose: keeps plan 02 tiny (single file, ~50% context); avoids the +"scavenger hunt" anti-pattern by establishing the API shape and component +contracts up front (Interface-First Task Ordering). + +Output: rewritten `app/api/mobile/dashboard/route.ts`, three new files +under `components/mobile/`. No edits to `app/mobile/dashboard/page.tsx` +(reserved for plan 02 to avoid same-wave file conflicts). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@docs/superpowers/specs/2026-05-03-mobile-shell-design.md +@CLAUDE.md + + +@app/api/dashboard/overview/route.ts +@app/api/status/workers/route.ts +@app/api/veeam/backup-status/route.ts +@app/api/mobile/dashboard/route.ts + + +@components/dashboard/kpi-card.tsx +@components/ui/card.tsx + + + + + +From `lib/services/postgres-client.ts` (singleton): + +```typescript +import postgresClient from '@/lib/services/postgres-client'; +// postgresClient.query(sql: string, params?: unknown[]): Promise<{ rows: T[] }> +``` + +From `app/api/dashboard/overview/route.ts` (already filters out scope-excluded +companies — the same filter idiom must be used in our endpoint): + +```sql +-- "open total" pattern +SELECT COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total + FROM tickets + WHERE (is_deleted = false OR is_deleted IS NULL) + AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false); + +-- "sla breaches" / "overdue tickets" pattern +COUNT(*) FILTER ( + WHERE completed_date IS NULL + AND due_date_time IS NOT NULL + AND due_date_time < NOW() +)::text AS sla_breaches +``` + +From `app/api/status/workers/route.ts` — last activity timestamp & in-flight +queries for the analyzer + RMM workers (`analyzer_jobs`, `rmm_executions`). + +From `migrations/030_create_workflow_engine_tables.sql`: + +```sql +-- workflow_executions.status enum: 'pending' | 'completed' | 'failed' +-- "stalled" = status='pending' AND created_at < NOW() - INTERVAL '5 minutes' +-- (workflow engine runs synchronously from webhook fire-and-forget) +``` + +From `app/api/veeam/backup-status/route.ts` (existing — we reuse `successRate24h` +or compute equivalent): + +```typescript +// successRate24h = (successJobs / totalJobs) * 100, rounded to 1 decimal +``` + +From `lib/auth-utils.ts`: + +```typescript +const { error } = await requireAuth(); +if (error) return error; +``` + + + + + + + Task 1: Rewrite /api/mobile/dashboard to return kpis/needsAttention/workers shape + app/api/mobile/dashboard/route.ts + + - app/api/mobile/dashboard/route.ts (current file — being completely replaced) + - app/api/dashboard/overview/route.ts (source-of-truth for KPI queries + scope filter) + - app/api/status/workers/route.ts (source-of-truth for analyzer/rmm worker queries) + - app/api/veeam/backup-status/route.ts (source-of-truth for backup success rate) + - migrations/030_create_workflow_engine_tables.sql (workflow_executions schema) + - lib/auth-utils.ts (requireAuth pattern) + - lib/services/postgres-client.ts (singleton import pattern) + - CLAUDE.md (no Zod in API routes; 503 for missing config; manual snake→camel transform) + + + - GET /api/mobile/dashboard returns 200 with JSON: { kpis: KpiResponse[], needsAttention: AttentionResponse[], workers: WorkerResponse[] } + - kpis array has exactly 4 entries with these `id` values in this order: 'open_total', 'opened_today', 'resolved_today', 'sla_breaches' + - needsAttention array has exactly 3 entries with these `id` values in this order: 'overdue_tickets', 'failed_backups', 'stalled_workflows' + - workers array has exactly 3 entries with these `id` values in this order: 'analyzer', 'rmm', 'backup_success_rate' + - Unauthenticated request returns whatever requireAuth() returns (401/redirect via existing helper) + - Database errors return 500 with { error, message } shape + + +Replace the entire contents of `app/api/mobile/dashboard/route.ts` with a new GET handler that returns the shape consumed by plan 02. + +Required response TypeScript shape (declare these as exported `interface`s at the top of the file so plan 02 can `import type` them): + +```typescript +export interface KpiResponse { + id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches'; + label: string; // e.g. "Open total", "Opened today", "Resolved today", "SLA breaches" + value: number; + caption?: string; // optional secondary line, e.g. "vs yesterday: 12" + tone?: 'default' | 'attention'; // 'attention' for sla_breaches when value > 0 +} + +export interface AttentionResponse { + id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows'; + label: string; // e.g. "Overdue tickets", "Failed backups (24h)", "Stalled workflows" + count: number; // 0 is allowed; the UI will style empty state + href: string; // destination route — see below +} + +export interface WorkerResponse { + id: 'analyzer' | 'rmm' | 'backup_success_rate'; + label: string; // e.g. "Analyzer", "RMM Overshell", "Backup success (24h)" + value: string; // human display: "12 in flight", "3 in flight", "98.4%" + status: 'ok' | 'warn' | 'down'; // see status rules below + href: string; // destination route — see below +} + +export interface MobileDashboardResponse { + kpis: KpiResponse[]; + needsAttention: AttentionResponse[]; + workers: WorkerResponse[]; +} +``` + +Implementation details (copy these patterns — do not invent SQL): + +1. Imports at the top: + ```typescript + import { NextResponse } from 'next/server'; + import { requireAuth } from '@/lib/auth-utils'; + import postgresClient from '@/lib/services/postgres-client'; + ``` + +2. Handler skeleton: + ```typescript + export async function GET() { + const { error } = await requireAuth(); + if (error) return error; + try { + const [/* result rows */] = await Promise.all([ /* queries */ ]); + return NextResponse.json({ kpis, needsAttention, workers }); + } catch (e) { + console.error('[/api/mobile/dashboard] failed:', e); + return NextResponse.json( + { error: 'Failed to load dashboard', message: e instanceof Error ? e.message : 'Unknown error' }, + { status: 500 }, + ); + } + } + ``` + +3. KPI queries — combine into a single ticket aggregate query, modeled exactly on the `today snapshot` query in `app/api/dashboard/overview/route.ts`: + ```sql + SELECT + COUNT(*) FILTER (WHERE completed_date IS NULL)::text AS open_total, + COUNT(*) FILTER (WHERE create_date::date = CURRENT_DATE)::text AS opened_today, + COUNT(*) FILTER (WHERE completed_date::date = CURRENT_DATE)::text AS resolved_today, + COUNT(*) FILTER ( + WHERE completed_date IS NULL + AND due_date_time IS NOT NULL + AND due_date_time < NOW() + )::text AS sla_breaches + FROM tickets + WHERE (is_deleted = false OR is_deleted IS NULL) + AND company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false) + ``` + Build kpis from this single row. For sla_breaches, set `tone: 'attention'` when value > 0, else 'default'. Other three default tone. Captions optional — leave undefined for now. + +4. Needs Attention queries — three parallel queries: + - `overdue_tickets` count = sla_breaches above (already computed — reuse the integer; do NOT requery). `href: '/tickets?overdue=true'`. + - `failed_backups` count: combine `veeam_backup_jobs` and `veeam_backup_agent_jobs` last_run >= NOW() - INTERVAL '24 hours' AND status = 'Failed' AND is_enabled = true (mirror the join in `app/api/veeam/backup-status/route.ts`): + ```sql + SELECT COUNT(*)::text AS count FROM ( + SELECT 1 FROM veeam_backup_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true AND status = 'Failed' + UNION ALL + SELECT 1 FROM veeam_backup_agent_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true AND status = 'Failed' + ) f + ``` + `href: '/backup-status'`. + - `stalled_workflows` count: workflow_executions with status='pending' older than 5 minutes: + ```sql + SELECT COUNT(*)::text AS count + FROM workflow_executions + WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes' + ``` + `href: '/admin/workflow'`. + +5. Worker queries — three parallel queries: + - Analyzer in-flight from `analyzer_jobs` (mirror `app/api/status/workers/route.ts`): + ```sql + SELECT COUNT(*) FILTER ( + WHERE status IN ('queued','fetching','triaging','itglue','analyzing','deep_review') + )::text AS in_flight, + COUNT(*) FILTER (WHERE status='failed' AND finished_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h + FROM analyzer_jobs + ``` + value: `${in_flight} in flight`. status: 'down' if fail_1h>0 AND in_flight=0, 'warn' if fail_1h>0, otherwise 'ok'. `href: '/admin/analytics'` (analyzer admin lives there per existing admin routes). + - RMM in-flight from `rmm_executions`: + ```sql + SELECT COUNT(*) FILTER (WHERE status IN ('queued','running'))::text AS in_flight, + COUNT(*) FILTER (WHERE status IN ('failed','timeout') AND completed_at >= NOW() - INTERVAL '1 hour')::text AS fail_1h + FROM rmm_executions + ``` + value: `${in_flight} in flight`. Same status rule as analyzer. `href: '/admin/rmm-overshell'`. + - Backup success rate (24h): mirror `app/api/veeam/backup-status/route.ts` calculation: + ```sql + SELECT + COUNT(*) FILTER (WHERE status = 'Success')::text AS success, + COUNT(*)::text AS total + FROM ( + SELECT status FROM veeam_backup_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true + UNION ALL + SELECT status FROM veeam_backup_agent_jobs WHERE last_run >= NOW() - INTERVAL '24 hours' AND is_enabled = true + ) j + ``` + pct = total > 0 ? Math.round((success/total) * 1000) / 10 : 100; value: `${pct}%`. status: 'ok' if pct >= 95, 'warn' if pct >= 80, 'down' otherwise. `href: '/backup-status'`. + +6. Wrap all 6 queries (1 KPI + 2 attention + 3 worker; the 3rd attention is computed from KPI row) in a single `Promise.all`. Five queries total. + +Do NOT add caching, do NOT introduce Zod, do NOT introduce SWR. Match the no-ORM, manual-transform Pulse pattern. + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/dashboard/route\.ts" || echo "OK: no type errors in route.ts" + + + - File `app/api/mobile/dashboard/route.ts` exports `MobileDashboardResponse`, `KpiResponse`, `AttentionResponse`, `WorkerResponse` interfaces (verify: `grep -E "^export interface (MobileDashboardResponse|KpiResponse|AttentionResponse|WorkerResponse)" app/api/mobile/dashboard/route.ts` returns 4 lines) + - File imports `requireAuth` from `@/lib/auth-utils` (verify: `grep "from '@/lib/auth-utils'" app/api/mobile/dashboard/route.ts` returns 1 line) + - File imports `postgresClient` from `@/lib/services/postgres-client` (verify: `grep "from '@/lib/services/postgres-client'" app/api/mobile/dashboard/route.ts` returns 1 line) + - File contains exactly one `Promise.all` and at least 5 `postgresClient.query` calls (verify: `grep -c "postgresClient.query" app/api/mobile/dashboard/route.ts` returns >= 5; `grep -c "Promise.all" app/api/mobile/dashboard/route.ts` returns 1) + - Response builder hard-codes the 4 KPI ids, 3 attention ids, 3 worker ids (verify: `grep -oE "'open_total'|'opened_today'|'resolved_today'|'sla_breaches'|'overdue_tickets'|'failed_backups'|'stalled_workflows'|'analyzer'|'rmm'|'backup_success_rate'" app/api/mobile/dashboard/route.ts | sort -u | wc -l` returns 10) + - File contains the workflow stalled query with `'pending'` and `'5 minutes'` (verify: `grep "workflow_executions" app/api/mobile/dashboard/route.ts` returns >= 1 line AND `grep "5 minutes" app/api/mobile/dashboard/route.ts` returns >= 1 line) + - File contains the company_scope exclusion (verify: `grep "company_scope" app/api/mobile/dashboard/route.ts` returns >= 1 line) + - No Zod, no recharts, no SWR imports (verify: `grep -E "from 'zod'|recharts|swr|@tanstack/react-query" app/api/mobile/dashboard/route.ts` returns nothing) + - Type-check passes for the file (verify: `npx tsc --noEmit --pretty 2>&1 | grep "app/api/mobile/dashboard/route.ts"` returns nothing) + + + Endpoint returns the new shape; type-check clean; existing imports + in `app/mobile/dashboard/page.tsx` will break (the old `DashboardData` + fields no longer exist) — that breakage is fixed in plan 02. Do not + edit the page in this task. + + + + + Task 2: Add KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow components + + components/mobile/KpiCardMobile.tsx, + components/mobile/NeedsAttentionStrip.tsx, + components/mobile/WorkerStatusRow.tsx + + + - components/dashboard/kpi-card.tsx (visual reference — what a desktop KPI card looks like; we are NOT copying this, just modelling after it) + - components/ui/card.tsx (shadcn Card primitive) + - components/mobile/HeaderBar.tsx (existing mobile component — file naming, styling tokens, leading comment block style) + - components/mobile/BottomNav.tsx (existing mobile component — Link from next/link, active-color pattern) + - app/mobile/dashboard/page.tsx (current page — see PRIORITY_COLOR/PRIORITY_TEXT pattern reused for tone) + - DESIGN.md (typography + tokens — read sections on Card vocabulary) + + + - KpiCardMobile renders a card with label, large numeric value, optional caption; tone='attention' adds a destructive left border; tone='default' is neutral + - NeedsAttentionStrip renders nothing when items=[] (empty fragment); when items present, renders a horizontally-scrollable row of compact cards each wrapped in a next/link + - WorkerStatusRow renders a 3-cell row of compact status pills, each wrapped in a next/link to the entry's href; status='ok' uses green dot, 'warn' amber dot, 'down' red dot + - All three are pure presentational client components — no fetch, no state beyond props + + +Create three new files under `components/mobile/`. All three start with `'use client';` and a leading multi-line `/* ComponentName — phase 03 (DASH-XX). */` block describing the component, mirroring the style of `components/mobile/HeaderBar.tsx` and `components/mobile/BottomNav.tsx`. + +### File 1: `components/mobile/KpiCardMobile.tsx` + +Exports a single component for the 2×2 KPI grid (DASH-01). + +```typescript +'use client'; + +/* KpiCardMobile — phase 03 (DASH-01). + * + * Phone-sized KPI card for the 2×2 dashboard grid. Renders a label, + * a large numeric value, and an optional caption. tone="attention" + * adds a left-edge destructive border for SLA breaches > 0. + * + * Pure presentational — no fetch, no state. Parent provides values. */ + +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; + +export type KpiTone = 'default' | 'attention'; + +interface KpiCardMobileProps { + label: string; + value: number | string; + caption?: string; + tone?: KpiTone; +} + +const TONE_BORDER: Record = { + default: 'border-l-transparent', + attention: 'border-l-destructive', +}; + +export function KpiCardMobile({ label, value, caption, tone = 'default' }: KpiCardMobileProps) { + const display = typeof value === 'number' ? value.toLocaleString() : value; + return ( + + +

{label}

+

{display}

+ {caption &&

{caption}

} +
+
+ ); +} +``` + +### File 2: `components/mobile/NeedsAttentionStrip.tsx` + +Exports the strip + an `NeedsAttentionItem` interface (DASH-02). + +```typescript +'use client'; + +/* NeedsAttentionStrip — phase 03 (DASH-02). + * + * Horizontal-scroll strip of compact attention cards. Each card shows a + * count + label and is a next/link to the destination view. The strip + * uses native horizontal overflow with snap-x for momentum scroll on + * iOS/Android. Renders nothing when items=[]. */ + +import Link from 'next/link'; +import { AlertTriangle, ChevronRight } from 'lucide-react'; + +export interface NeedsAttentionItem { + id: string; + label: string; + count: number; + href: string; +} + +interface NeedsAttentionStripProps { + items: NeedsAttentionItem[]; +} + +export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps) { + if (items.length === 0) return null; + return ( +
+

+ Needs attention +

+
+ {items.map(item => ( + +
+ 0 ? 'text-destructive' : 'text-muted-foreground'}`} /> + +
+

0 ? 'text-destructive' : ''}`}> + {item.count} +

+

{item.label}

+ + ))} +
+
+ ); +} +``` + +### File 3: `components/mobile/WorkerStatusRow.tsx` + +Exports the row + a `WorkerStatusEntry` interface (DASH-03). + +```typescript +'use client'; + +/* WorkerStatusRow — phase 03 (DASH-03). + * + * Compact 3-cell read-only status row showing analyzer worker, RMM worker, + * and backup success rate. Each cell is a next/link to the corresponding + * desktop admin page. status='ok' = emerald dot, 'warn' = amber, 'down' = + * destructive. */ + +import Link from 'next/link'; +import { ExternalLink } from 'lucide-react'; + +export type WorkerStatus = 'ok' | 'warn' | 'down'; + +export interface WorkerStatusEntry { + id: string; + label: string; + value: string; + status: WorkerStatus; + href: string; +} + +interface WorkerStatusRowProps { + entries: WorkerStatusEntry[]; +} + +const DOT_COLOR: Record = { + ok: 'bg-emerald-500', + warn: 'bg-amber-500', + down: 'bg-destructive', +}; + +export function WorkerStatusRow({ entries }: WorkerStatusRowProps) { + return ( +
+

+ Workers & backups +

+
+ {entries.map(e => ( + +
+
+ ); +} +``` + +Use `cn` from `@/lib/utils` only where actually needed; the simple ternary class strings shown above are fine. Do NOT introduce recharts (DASH-04). Do NOT introduce framer-motion or any animation lib. Stick to lucide icons and shadcn Card primitive. +
+ + npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(KpiCardMobile|NeedsAttentionStrip|WorkerStatusRow)\.tsx" || echo "OK: no type errors in new components" + + + - All three files exist (verify: `ls components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx`) + - Each file starts with `'use client';` (verify: `head -1 components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx | grep -c "'use client';"` returns 3) + - `KpiCardMobile` is exported (verify: `grep -E "^export function KpiCardMobile" components/mobile/KpiCardMobile.tsx` returns 1 line) + - `NeedsAttentionStrip` and `NeedsAttentionItem` are both exported (verify: `grep -E "^export (function NeedsAttentionStrip|interface NeedsAttentionItem)" components/mobile/NeedsAttentionStrip.tsx | wc -l` returns 2) + - `WorkerStatusRow` and `WorkerStatusEntry` are both exported (verify: `grep -E "^export (function WorkerStatusRow|interface WorkerStatusEntry|type WorkerStatus)" components/mobile/WorkerStatusRow.tsx | wc -l` returns >= 2) + - No `recharts` import in any of the three files (verify: `grep recharts components/mobile/KpiCardMobile.tsx components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx` returns nothing) + - All three import next/link only where needed (verify: `grep -L "from 'next/link'" components/mobile/NeedsAttentionStrip.tsx components/mobile/WorkerStatusRow.tsx` returns nothing — both must import it) + - `KpiCardMobile.tsx` imports `Card`/`CardContent` from `@/components/ui/card` (verify: `grep "from '@/components/ui/card'" components/mobile/KpiCardMobile.tsx` returns 1 line) + - Type-check passes for the new files (verify: `npx tsc --noEmit --pretty 2>&1 | grep -E "components/mobile/(KpiCardMobile|NeedsAttentionStrip|WorkerStatusRow)\.tsx"` returns nothing) + + + Three component files compile clean; exports match the names plan 02 + will import. No edits to `app/mobile/dashboard/page.tsx` (reserved for + plan 02). + +
+ +
+ + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → /api/mobile/dashboard | Authenticated browser request from `/mobile/dashboard` page; auth enforced by `requireAuth()` and middleware. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-01 | Information Disclosure | `/api/mobile/dashboard` | mitigate | Call `requireAuth()` at the top of GET; return its error response unchanged so unauthenticated callers get 401/redirect identical to other authenticated routes (mirrors `app/api/dashboard/overview/route.ts`). | +| T-03-02 | Information Disclosure | KPI ticket queries | mitigate | All ticket SELECTs include `company_id NOT IN (SELECT company_id FROM company_scope WHERE in_scope = false)` so out-of-scope companies aren't counted/leaked — same idiom as the desktop overview route. | +| T-03-03 | Tampering | SQL injection via query params | accept | The endpoint takes no query parameters; all SQL is parameterless string-literal SQL. No interpolation of user input. | +| T-03-04 | Information Disclosure | Worker queries | accept | `analyzer_jobs`, `rmm_executions`, `workflow_executions`, `veeam_backup_jobs/agent_jobs` are operator-internal tables; counts only (no row content) are returned. No PII exposure. | +| T-03-05 | Tampering | Card/strip click destinations | mitigate | All `href` strings are hard-coded route literals built server-side (`/tickets?overdue=true`, `/admin/workflow`, `/backup-status`, etc.) — clients cannot influence them. Existing Better Auth middleware protects each destination route. | + + + +- `npx tsc --noEmit --pretty` exits clean for the four new/changed files +- Manual smoke (optional, not part of automation): `curl -b "$COOKIE" http://localhost:3100/api/mobile/dashboard | jq '.kpis | length, .needsAttention | length, .workers | length'` returns `4 3 3` + + + +- `/api/mobile/dashboard` returns the documented `MobileDashboardResponse` shape +- Three new components exist under `components/mobile/` with the documented exports +- No edits to `app/mobile/dashboard/page.tsx` in this plan (reserved for plan 02) +- Type-check passes + + + +After completion, create `.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md` documenting: +- Final response shape (paste the `MobileDashboardResponse` interface) +- Component export signatures +- Any deviations from this plan and why + diff --git a/.planning/phases/03-dashboard-restyle/03-02-PLAN.md b/.planning/phases/03-dashboard-restyle/03-02-PLAN.md new file mode 100644 index 0000000..565d211 --- /dev/null +++ b/.planning/phases/03-dashboard-restyle/03-02-PLAN.md @@ -0,0 +1,426 @@ +--- +phase: 03-dashboard-restyle +plan: 02 +type: execute +wave: 2 +depends_on: + - 03-01 +files_modified: + - app/mobile/dashboard/page.tsx +autonomous: false +requirements: + - DASH-01 + - DASH-02 + - DASH-03 + - DASH-04 +objective: | + Replace the body of /mobile/dashboard so it renders the new spec §6.1 + layout: 2×2 KPI grid → "Needs Attention" horizontal strip → worker/backup + status row, fed by /api/mobile/dashboard. Drop all recharts/charts and the + old by_status/by_queue/by_priority/sla/recent sections. + +must_haves: + truths: + - "Visiting /mobile/dashboard renders four KPI cards in a 2×2 grid (no 1×4 row, no list)" + - "Below the grid, a horizontally-scrollable Needs Attention strip surfaces overdue tickets, failed backups, and stalled workflows" + - "Below the strip, a 3-row worker/backup status block links to /tickets?overdue=true, /backup-status, /admin/workflow, /admin/analytics, /admin/rmm-overshell as appropriate" + - "The page imports zero recharts/chart components and renders no chart on phone widths" + - "Tapping a Needs Attention card navigates to its href (next/link)" + - "Tapping a worker status row navigates to its desktop admin href (next/link)" + artifacts: + - path: "app/mobile/dashboard/page.tsx" + provides: "Replaced page body wiring KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow against /api/mobile/dashboard" + min_lines: 40 + contains: "KpiCardMobile" + key_links: + - from: "app/mobile/dashboard/page.tsx" + to: "/api/mobile/dashboard" + via: "fetch in useEffect" + pattern: "fetch\\('/api/mobile/dashboard'\\)" + - from: "app/mobile/dashboard/page.tsx" + to: "components/mobile/KpiCardMobile.tsx" + via: "import KpiCardMobile" + pattern: "from '@/components/mobile/KpiCardMobile'" + - from: "app/mobile/dashboard/page.tsx" + to: "components/mobile/NeedsAttentionStrip.tsx" + via: "import NeedsAttentionStrip" + pattern: "from '@/components/mobile/NeedsAttentionStrip'" + - from: "app/mobile/dashboard/page.tsx" + to: "components/mobile/WorkerStatusRow.tsx" + via: "import WorkerStatusRow" + pattern: "from '@/components/mobile/WorkerStatusRow'" +--- + + +Replace the body of `app/mobile/dashboard/page.tsx` to render the new spec +§6.1 layout. Plan 01 already shipped the API and components; this plan is +pure UI assembly. + +Purpose: deliver the user-visible Phase 3 outcome — manager opens +`/mobile/dashboard`, sees four KPIs in a 2×2 grid, a horizontal Needs +Attention strip, and a compact worker/backup status block. No charts. + +Output: rewritten `app/mobile/dashboard/page.tsx`. No new components. +No edits to API routes (already shipped in plan 01). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-dashboard-restyle/03-01-SUMMARY.md +@docs/superpowers/specs/2026-05-03-mobile-shell-design.md +@CLAUDE.md + + +@app/mobile/dashboard/page.tsx + + +@app/mobile/layout.tsx + + +@components/mobile/KpiCardMobile.tsx +@components/mobile/NeedsAttentionStrip.tsx +@components/mobile/WorkerStatusRow.tsx + + +@app/api/mobile/dashboard/route.ts + + + + +From `app/api/mobile/dashboard/route.ts`: + +```typescript +export interface KpiResponse { + id: 'open_total' | 'opened_today' | 'resolved_today' | 'sla_breaches'; + label: string; + value: number; + caption?: string; + tone?: 'default' | 'attention'; +} +export interface AttentionResponse { + id: 'overdue_tickets' | 'failed_backups' | 'stalled_workflows'; + label: string; + count: number; + href: string; +} +export interface WorkerResponse { + id: 'analyzer' | 'rmm' | 'backup_success_rate'; + label: string; + value: string; + status: 'ok' | 'warn' | 'down'; + href: string; +} +export interface MobileDashboardResponse { + kpis: KpiResponse[]; + needsAttention: AttentionResponse[]; + workers: WorkerResponse[]; +} +``` + +From `components/mobile/KpiCardMobile.tsx`: +```typescript +export function KpiCardMobile(props: { label: string; value: number | string; caption?: string; tone?: 'default'|'attention' }): JSX.Element; +``` + +From `components/mobile/NeedsAttentionStrip.tsx`: +```typescript +export interface NeedsAttentionItem { id: string; label: string; count: number; href: string } +export function NeedsAttentionStrip(props: { items: NeedsAttentionItem[] }): JSX.Element | null; +``` + +From `components/mobile/WorkerStatusRow.tsx`: +```typescript +export type WorkerStatus = 'ok' | 'warn' | 'down'; +export interface WorkerStatusEntry { id: string; label: string; value: string; status: WorkerStatus; href: string } +export function WorkerStatusRow(props: { entries: WorkerStatusEntry[] }): JSX.Element; +``` + + + + + + + Task 1: Replace mobile dashboard page body with the new 3-section layout + app/mobile/dashboard/page.tsx + + - app/mobile/dashboard/page.tsx (current — being completely replaced) + - app/mobile/layout.tsx (confirms header/bottom nav are layout-provided; page renders into
) + - app/api/mobile/dashboard/route.ts (response shape source-of-truth) + - components/mobile/KpiCardMobile.tsx (import contract) + - components/mobile/NeedsAttentionStrip.tsx (import contract) + - components/mobile/WorkerStatusRow.tsx (import contract) + - components/mobile/HeaderBar.tsx (page-title pattern — pages render their own H1; header has no title) + - CLAUDE.md (no SWR, no server actions, useState + fetch pattern) + + + - Page is a `'use client'` component, default export + - On mount, fetches GET /api/mobile/dashboard exactly once and stores the response + - While loading: shows a centered RefreshCw spinner (match existing skeleton pattern) + - On error: shows the error message in a destructive-tinted block + a Retry button that re-runs the fetch + - On success: renders an H1 ("Dashboard"), then 3 sections in this order: + 1. 2×2 grid of KpiCardMobile (4 entries from response.kpis), tone derived from `kpi.tone`, caption from `kpi.caption` + 2. NeedsAttentionStrip with `items=response.needsAttention` mapped to NeedsAttentionItem + 3. WorkerStatusRow with `entries=response.workers` mapped to WorkerStatusEntry + - Header refresh button (RefreshCw icon, top-right of the H1 row) re-runs the fetch + - Page imports zero recharts/chart libraries + + +Completely replace the contents of `app/mobile/dashboard/page.tsx`. The new file is one self-contained client component plus typed state. + +Required structure: + +```typescript +'use client'; + +/* /mobile/dashboard — phase 03 (DASH-01..04). + * + * Three sections, top-to-bottom: + * 1. 2×2 KPI grid (DASH-01) + * 2. Needs Attention (DASH-02) + * 3. Worker/backup row (DASH-03) + * + * No charts on phone widths (DASH-04). Header + bottom nav are provided + * by app/mobile/layout.tsx; this page only renders the H1 and body. */ + +import { useEffect, useState } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { KpiCardMobile } from '@/components/mobile/KpiCardMobile'; +import { NeedsAttentionStrip } from '@/components/mobile/NeedsAttentionStrip'; +import { WorkerStatusRow } from '@/components/mobile/WorkerStatusRow'; +import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route'; + +export default function MobileDashboard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + async function load() { + setLoading(true); + setError(null); + try { + const r = await fetch('/api/mobile/dashboard'); + if (!r.ok) { + const body = (await r.json().catch(() => ({}))) as { error?: string; message?: string }; + throw new Error(body.message ?? body.error ?? `HTTP ${r.status}`); + } + setData((await r.json()) as MobileDashboardResponse); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + + useEffect(() => { void load(); }, []); + + return ( +
+
+

Dashboard

+ +
+ + {error && !loading && ( +
+

Failed to load

+

{error}

+ +
+ )} + + {loading && !data && ( +
+ +
+ )} + + {data && ( + <> + {/* DASH-01: 2×2 KPI grid */} +
+ {data.kpis.map(kpi => ( + + ))} +
+ + {/* DASH-02: Needs Attention horizontal strip */} + ({ + id: a.id, + label: a.label, + count: a.count, + href: a.href, + }))} + /> + + {/* DASH-03: Worker/backup status row */} + ({ + id: w.id, + label: w.label, + value: w.value, + status: w.status, + href: w.href, + }))} + /> + + )} +
+ ); +} +``` + +Constraints: +- `import type { MobileDashboardResponse } from '@/app/api/mobile/dashboard/route'` — type-only import is fine in Next.js 16 (the route file marks the export as `interface`, no runtime cost). If TypeScript complains about importing types from a route file, fall back to redefining the same shape locally in this file as `interface MobileDashboardResponse { ... }` matching the source-of-truth in plan 01's SUMMARY exactly. Either is acceptable. +- Do NOT import any of these legacy fields used by the old page: `open_total`, `by_status`, `by_queue`, `by_priority`, `sla`, `recent`. +- Do NOT add a separate "header" — the layout already provides one (HeaderBar in `app/mobile/layout.tsx`). The H1 inside the page body is per spec §5.1 ("No page title in the header — pages render their own H1"). +- Do NOT introduce recharts, react-day-picker, framer-motion, swr, or react-query. The constraint is strict (DASH-04). +- Do NOT introduce a `next/dynamic` import for charts. Just don't use charts. +- Keep the file under ~120 lines. The body should look like the example above, not a re-skin of the old page. +
+ + npx tsc --noEmit --pretty 2>&1 | grep "app/mobile/dashboard/page.tsx" || echo "OK: type-check clean" + + + - File starts with `'use client';` (verify: `head -1 app/mobile/dashboard/page.tsx` returns `'use client';`) + - File has a default export named `MobileDashboard` (verify: `grep -E "^export default function MobileDashboard" app/mobile/dashboard/page.tsx` returns 1 line) + - File imports all three new components (verify: `grep -c "from '@/components/mobile/\(KpiCardMobile\|NeedsAttentionStrip\|WorkerStatusRow\)'" app/mobile/dashboard/page.tsx` returns 3) + - File fetches `/api/mobile/dashboard` (verify: `grep "fetch('/api/mobile/dashboard')" app/mobile/dashboard/page.tsx` returns 1 line) + - File contains a `grid-cols-2` section for the KPI grid (verify: `grep "grid-cols-2" app/mobile/dashboard/page.tsx` returns >= 1 line) + - File contains an `

Dashboard

` (verify: `grep -E "]*>Dashboard" app/mobile/dashboard/page.tsx` returns 1 line) + - File does NOT import recharts/swr/react-query/framer-motion (verify: `grep -E "from 'recharts'|from 'swr'|from '@tanstack/react-query'|from 'framer-motion'" app/mobile/dashboard/page.tsx` returns nothing) + - File does NOT contain any of the old field names (verify: `grep -E "by_status|by_queue|by_priority|response_met|resolution_met|PRIORITY_COLOR|PRIORITY_TEXT" app/mobile/dashboard/page.tsx` returns nothing) + - File does NOT contain a `` to `/mobile/tickets/${...}` (the old "Recent Activity" list is gone) (verify: `grep "/mobile/tickets/\${" app/mobile/dashboard/page.tsx` returns nothing) + - Type-check passes for the page (verify: `npx tsc --noEmit --pretty 2>&1 | grep "app/mobile/dashboard/page.tsx"` returns nothing) + - File is at most 130 lines (verify: `wc -l app/mobile/dashboard/page.tsx` returns a number <= 130) +
+ + `/mobile/dashboard` renders the new 3-section layout against the plan-01 + API. Type-check clean. No charts. + + + + + Task 2: Human verification — open /mobile/dashboard on a phone-width viewport + app/mobile/dashboard/page.tsx + + - app/mobile/dashboard/page.tsx (the file just modified — confirms what to look for in the browser) + + + Phase 3 deliverable: `/mobile/dashboard` rebuilt per spec §6.1. + - 2×2 KPI grid (DASH-01) + - Horizontal-scroll Needs Attention strip (DASH-02) + - Worker/backup status row with desktop-admin links (DASH-03) + - Zero charts (DASH-04) + + +1. Start the dev server: `npm run dev` (port 3100) +2. Open Chrome DevTools, toggle device emulation, pick "iPhone 15 Pro" (393×852). +3. Navigate to http://localhost:3100/mobile/dashboard (sign in if prompted). +4. Verify each item below: + a. The header is the new shell HeaderBar (Wulf mark + Pulse wordmark + Bell + avatar) — NOT a page-internal "Ticket Dashboard" header. + b. There is exactly one H1 in the page body that says "Dashboard". + c. Below the H1, you see four KPI cards in a 2×2 grid (Open total, Opened today, Resolved today, SLA breaches). The SLA breaches card should have a destructive (red) left border if the count > 0, otherwise neutral. + d. Below the grid, a "Needs attention" strip with three cards (Overdue tickets / Failed backups / Stalled workflows) scrolls horizontally with momentum. Tapping each card navigates correctly: + - Overdue tickets → `/tickets?overdue=true` + - Failed backups → `/backup-status` + - Stalled workflows → `/admin/workflow` + e. Below the strip, a "Workers & backups" block with three rows: + - Analyzer → `/admin/analytics` + - RMM Overshell → `/admin/rmm-overshell` + - Backup success (24h) → `/backup-status` + Each row has a status dot (green/amber/red) on the left and an external-link icon on the right. + f. There is NO chart (no recharts canvas/SVG) anywhere on the page. + g. The page scrolls under the sticky header and content does NOT hide behind the bottom nav. + h. Tapping the refresh button (top-right of the H1 row) spins the icon and reloads the data without a full-page nav. +5. Sanity command: `grep -rn recharts app/mobile/dashboard/ components/mobile/` returns nothing. + + + Pause execution for human verification. The implementer cannot + visually confirm the spec §6.1 layout — a human running on a real + phone-width viewport must walk through the steps in + and approve. If any step fails, the human describes the issue and + Task 1 is revised. + + + grep -rn "recharts" app/mobile/dashboard/ components/mobile/ 2>/dev/null && exit 1 || echo "OK: no recharts in mobile dashboard or new mobile components" + + + - Human runs through every step a–h of and reports any failures + - Sanity grep returns no `recharts` references in `app/mobile/dashboard/` or `components/mobile/` + - Approval signal received from the human (the resume-signal contents) + + + Human types "approved" (or describes issues to fix). On approval, the + phase is shippable. On issues, return to Task 1 with the human's notes. + + Type "approved" or describe issues + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → server | No new boundaries — page consumes the existing authenticated `/api/mobile/dashboard` endpoint. | +| /mobile/* → /admin/*, /backup-status, /tickets | All link destinations are existing authenticated routes; Better Auth middleware enforces session on the destination. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-03-06 | Elevation of Privilege | Tile/card click destinations | accept | All `href` values are emitted by the server endpoint (plan 01) and rendered as `next/link`. The client cannot influence destinations beyond what the server returned, and Better Auth middleware enforces the session/role required for each destination route. No new privilege boundary. | +| T-03-07 | Information Disclosure | Error rendering | mitigate | Server errors are surfaced via `body.message ?? body.error ?? 'HTTP {status}'`. No stack trace or DB schema is rendered. The endpoint only returns sanitized `{ error, message }` per CLAUDE.md convention. | +| T-03-08 | Information Disclosure | Empty/zero counts | accept | Counts of 0 are rendered as "0" rather than hidden. This is intentional — a manager seeing "0 overdue tickets" is the desired signal. No PII surfaced. | + + + +- `npx tsc --noEmit --pretty` exits clean (page + components + route) +- `grep -rn "recharts" app/mobile/dashboard/ components/mobile/` returns nothing +- Manual: page renders the documented 3-section layout on a phone-width viewport (Task 2 checkpoint) + + + +- `/mobile/dashboard` page renders 2×2 KPI grid, Needs Attention strip, worker/backup status row in that order +- All Needs Attention cards and worker rows are tappable links to the documented destinations +- No charts/recharts on the page (DASH-04) +- Type-check passes +- Human verification approves the layout (Task 2) + + + +After completion, create `.planning/phases/03-dashboard-restyle/03-02-SUMMARY.md` documenting: +- Final file structure of the new page +- Whether `import type { MobileDashboardResponse }` worked or fell back to a local interface +- Any deviations from this plan and why +- Screenshot path / link if captured during checkpoint (optional) +