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
27 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | objective | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-dashboard-restyle | 01 | execute | 1 |
|
true |
|
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. |
|
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):
-
Imports at the top:
import { NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; -
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 }, ); } } -
KPI queries — combine into a single ticket aggregate query, modeled exactly on the
today snapshotquery inapp/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. -
Needs Attention queries — three parallel queries:
overdue_ticketscount = sla_breaches above (already computed — reuse the integer; do NOT requery).href: '/tickets?overdue=true'.failed_backupscount: combineveeam_backup_jobsandveeam_backup_agent_jobslast_run >= NOW() - INTERVAL '24 hours' AND status = 'Failed' AND is_enabled = true (mirror the join inapp/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' ) fhref: '/backup-status'.stalled_workflowscount: 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'.
-
Worker queries — three parallel queries:
- Analyzer in-flight from
analyzer_jobs(mirrorapp/api/status/workers/route.ts):
value: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${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:
value: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${in_flight} in flight. Same status rule as analyzer.href: '/admin/rmm-overshell'. - Backup success rate (24h): mirror
app/api/veeam/backup-status/route.tscalculation:
pct = total > 0 ? Math.round((success/total) * 1000) / 10 : 100; value: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}%. status: 'ok' if pct >= 95, 'warn' if pct >= 80, 'down' otherwise.href: '/backup-status'.
- Analyzer in-flight from
-
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.
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 & 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> |
<success_criteria>
/api/mobile/dashboardreturns the documentedMobileDashboardResponseshape- Three new components exist under
components/mobile/with the documented exports - No edits to
app/mobile/dashboard/page.tsxin this plan (reserved for plan 02) - Type-check passes </success_criteria>