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) +