wulf-pulse/.planning/phases/03-dashboard-restyle/03-01-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

27 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements objective must_haves
03-dashboard-restyle 01 execute 1
app/api/mobile/dashboard/route.ts
components/mobile/KpiCardMobile.tsx
components/mobile/NeedsAttentionStrip.tsx
components/mobile/WorkerStatusRow.tsx
true
DASH-01
DASH-02
DASH-03
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.
truths artifacts key_links
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
path provides contains
app/api/mobile/dashboard/route.ts Single GET endpoint shaped for the new mobile dashboard sections kpis: [], needsAttention: [], workers: [
path provides exports
components/mobile/KpiCardMobile.tsx Reusable phone-sized KPI card component
KpiCardMobile
path provides exports
components/mobile/NeedsAttentionStrip.tsx Horizontal-scroll strip rendering NeedsAttention cards
NeedsAttentionStrip
NeedsAttentionItem
path provides exports
components/mobile/WorkerStatusRow.tsx Compact 3-cell worker/backup status row
WorkerStatusRow
WorkerStatusEntry
from to via pattern
components/mobile/NeedsAttentionStrip.tsx next/link Link href={item.href} from 'next/link'
from to via pattern
components/mobile/WorkerStatusRow.tsx next/link Link href={entry.href} from 'next/link'
from to via pattern
app/api/mobile/dashboard/route.ts postgresClient single Promise.all of parameterised queries 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).

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

import postgresClient from '@/lib/services/postgres-client';
// postgresClient.query<T>(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):

-- "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:

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

// successRate24h = (successJobs / totalJobs) * 100, rounded to 1 decimal

From lib/auth-utils.ts:

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 interfaces at the top of the file so plan 02 can import type them):

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:

    import { NextResponse } from 'next/server';
    import { requireAuth } from '@/lib/auth-utils';
    import postgresClient from '@/lib/services/postgres-client';
    
  2. Handler skeleton:

    export async function GET() {
      const { error } = await requireAuth();
      if (error) return error;
      try {
        const [/* result rows */] = await Promise.all([ /* queries */ ]);
        return NextResponse.json<MobileDashboardResponse>({ 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:

    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):
      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:
      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):
      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:
      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:
      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" <acceptance_criteria> - 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) </acceptance_criteria> 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).

'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<KpiTone, string> = {
  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 (
    <Card className={cn('h-full border-l-2', TONE_BORDER[tone])}>
      <CardContent className="p-4 flex flex-col gap-1">
        <p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</p>
        <p className="text-3xl font-bold tabular-nums">{display}</p>
        {caption && <p className="text-xs text-muted-foreground">{caption}</p>}
      </CardContent>
    </Card>
  );
}

File 2: components/mobile/NeedsAttentionStrip.tsx

Exports the strip + an NeedsAttentionItem interface (DASH-02).

'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 (
    <div>
      <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
        Needs attention
      </p>
      <div className="-mx-4 px-4 flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1">
        {items.map(item => (
          <Link
            key={item.id}
            href={item.href}
            className="snap-start shrink-0 w-44 rounded-2xl border bg-card p-3 hover:bg-accent transition-colors"
          >
            <div className="flex items-start justify-between">
              <AlertTriangle className={`w-4 h-4 ${item.count > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
              <ChevronRight className="w-4 h-4 text-muted-foreground" />
            </div>
            <p className={`mt-2 text-2xl font-bold tabular-nums ${item.count > 0 ? 'text-destructive' : ''}`}>
              {item.count}
            </p>
            <p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
          </Link>
        ))}
      </div>
    </div>
  );
}

File 3: components/mobile/WorkerStatusRow.tsx

Exports the row + a WorkerStatusEntry interface (DASH-03).

'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<WorkerStatus, string> = {
  ok: 'bg-emerald-500',
  warn: 'bg-amber-500',
  down: 'bg-destructive',
};

export function WorkerStatusRow({ entries }: WorkerStatusRowProps) {
  return (
    <div>
      <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
        Workers &amp; backups
      </p>
      <div className="rounded-2xl border divide-y overflow-hidden">
        {entries.map(e => (
          <Link
            key={e.id}
            href={e.href}
            className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
          >
            <span className={`inline-block w-2 h-2 rounded-full shrink-0 ${DOT_COLOR[e.status]}`} aria-hidden="true" />
            <span className="text-sm font-medium flex-1">{e.label}</span>
            <span className="text-sm tabular-nums text-muted-foreground">{e.value}</span>
            <ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
          </Link>
        ))}
      </div>
    </div>
  );
}

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" <acceptance_criteria> - 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) </acceptance_criteria> 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).

<threat_model>

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

<success_criteria>

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