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 (
+
+ );
+}
+```
+
+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)
+
+
+