wulf-pulse/.planning/phases/03-dashboard-restyle/03-02-PLAN.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
2026-05-03 18:01:14 -04:00

19 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements objective must_haves
03-dashboard-restyle 02 execute 2
03-01
app/mobile/dashboard/page.tsx
false
DASH-01
DASH-02
DASH-03
DASH-04
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.
truths artifacts key_links
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)
path provides min_lines contains
app/mobile/dashboard/page.tsx Replaced page body wiring KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow against /api/mobile/dashboard 40 KpiCardMobile
from to via pattern
app/mobile/dashboard/page.tsx /api/mobile/dashboard fetch in useEffect fetch('/api/mobile/dashboard')
from to via pattern
app/mobile/dashboard/page.tsx components/mobile/KpiCardMobile.tsx import KpiCardMobile from '@/components/mobile/KpiCardMobile'
from to via pattern
app/mobile/dashboard/page.tsx components/mobile/NeedsAttentionStrip.tsx import NeedsAttentionStrip from '@/components/mobile/NeedsAttentionStrip'
from to via pattern
app/mobile/dashboard/page.tsx components/mobile/WorkerStatusRow.tsx import WorkerStatusRow 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).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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:

export function KpiCardMobile(props: { label: string; value: number | string; caption?: string; tone?: 'default'|'attention' }): JSX.Element;

From components/mobile/NeedsAttentionStrip.tsx:

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:

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:

'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<MobileDashboardResponse | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(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 (
    <div className="p-4 space-y-5">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-bold">Dashboard</h1>
        <button
          type="button"
          onClick={load}
          disabled={loading}
          aria-label="Refresh dashboard"
          className="p-2 rounded-full hover:bg-accent disabled:opacity-40"
        >
          <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
        </button>
      </div>

      {error && !loading && (
        <div className="rounded-xl border border-destructive/50 bg-destructive/5 p-4">
          <p className="text-sm font-medium text-destructive">Failed to load</p>
          <p className="text-xs text-muted-foreground mt-1">{error}</p>
          <button
            type="button"
            onClick={load}
            className="mt-3 text-xs font-medium text-primary hover:underline"
          >
            Retry
          </button>
        </div>
      )}

      {loading && !data && (
        <div className="flex items-center justify-center h-64">
          <RefreshCw className="w-6 h-6 animate-spin text-muted-foreground" />
        </div>
      )}

      {data && (
        <>
          {/* DASH-01: 2×2 KPI grid */}
          <div className="grid grid-cols-2 gap-3">
            {data.kpis.map(kpi => (
              <KpiCardMobile
                key={kpi.id}
                label={kpi.label}
                value={kpi.value}
                caption={kpi.caption}
                tone={kpi.tone ?? 'default'}
              />
            ))}
          </div>

          {/* DASH-02: Needs Attention horizontal strip */}
          <NeedsAttentionStrip
            items={data.needsAttention.map(a => ({
              id: a.id,
              label: a.label,
              count: a.count,
              href: a.href,
            }))}
          />

          {/* DASH-03: Worker/backup status row */}
          <WorkerStatusRow
            entries={data.workers.map(w => ({
              id: w.id,
              label: w.label,
              value: w.value,
              status: w.status,
              href: w.href,
            }))}
          />
        </>
      )}
    </div>
  );
}

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" <acceptance_criteria>
    • 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 <h1>Dashboard</h1> (verify: grep -E "<h1[^>]*>Dashboard</h1>" 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 <Link> 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) </acceptance_criteria> /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 ah 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

<threat_model>

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.
</threat_model>
- `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)

<success_criteria>

  • /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) </success_criteria>
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)