docs(05): create phase plan

This commit is contained in:
lorentz 2026-05-03 19:48:15 -04:00
parent 6a4a55cb9c
commit a0ccd14044
3 changed files with 989 additions and 2 deletions

View file

@ -99,7 +99,9 @@ Decimal phases appear between their surrounding integers in numeric order.
**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
**Plans**: 2 plans
- [ ] 05-01-PLAN.md — FinanceRow + FinanceSkeleton helper components (FIN-01, FIN-02)
- [ ] 05-02-PLAN.md — Rewrite app/mobile/finance/page.tsx to KPI grid + stacked lists + shadcn Collapsibles (FIN-01, FIN-02)
**UI hint**: yes
### Phase 6: Analyzer Feed (NEW)
@ -150,7 +152,7 @@ Phases execute in numeric order. Phase 2 unblocks Phases 37 (any order, paral
| 2. Mobile Shell + More Drawer | 0/TBD | Not started | - |
| 3. Dashboard Restyle | 0/2 | Not started | - |
| 4. Tickets Restyle | 0/3 | Not started | - |
| 5. Finance Restyle | 0/TBD | Not started | - |
| 5. Finance Restyle | 0/2 | Not started | - |
| 6. Analyzer Feed | 0/TBD | Not started | - |
| 7. Engagement Overview | 0/TBD | Not started | - |
| 8. Engagement User Profile | 0/TBD | Not started | - |

View file

@ -0,0 +1,309 @@
---
phase: 05-finance-restyle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- components/mobile/FinanceRow.tsx
- components/mobile/FinanceSkeleton.tsx
autonomous: true
requirements: [FIN-01, FIN-02]
must_haves:
truths:
- "A 2-line stacked row (customer + amount on line 1, secondary metadata on line 2) is rendered by a reusable FinanceRow component, used by both invoice rows and payment rows in Plan 02"
- "An initial-load skeleton (4 KPI tiles + 1 aging row + 3 list-row skeletons) is rendered by a FinanceSkeleton component, ready for Plan 02 to drop into the loading branch"
artifacts:
- path: "components/mobile/FinanceRow.tsx"
provides: "Reusable 2-line stacked row for invoice and payment lists (D-06)"
exports: ["FinanceRow", "FinanceRowProps"]
- path: "components/mobile/FinanceSkeleton.tsx"
provides: "Initial-load skeleton matching final page layout (D-17)"
exports: ["FinanceSkeleton"]
key_links:
- from: "components/mobile/FinanceRow.tsx"
to: "components/ui/skeleton.tsx (NOT used here — pure presentational)"
via: "(no DB or fetch — pure presentational)"
pattern: "export function FinanceRow"
- from: "components/mobile/FinanceSkeleton.tsx"
to: "components/ui/skeleton.tsx"
via: "import { Skeleton }"
pattern: "from '@/components/ui/skeleton'"
---
<objective>
Extract two pure-presentational helper components for the Phase 5 Finance page rewrite:
`FinanceRow` (the 2-line stacked card row used by both invoice and payment lists per
UI-SPEC §"Invoice / Payment List Rows"), and `FinanceSkeleton` (the initial-load
placeholder per UI-SPEC §"Skeleton Loading State", D-17).
Purpose: Plan 02 consumes both directly. Extracting first means Plan 02 can focus on
composition (data plumbing, Collapsibles, KPI tiles, empty/error states) without
inline duplication of the row JSX or a sprawling skeleton block. Both components are
internal helpers — no public app-level export needed.
Output: Two new files in `components/mobile/`, no changes anywhere else.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/05-finance-restyle/05-CONTEXT.md
@.planning/phases/05-finance-restyle/05-UI-SPEC.md
@.planning/phases/04-tickets-restyle/04-01-SUMMARY.md
@CLAUDE.md
<interfaces>
<!-- shadcn primitives available — no new deps -->
From components/ui/skeleton.tsx:
```typescript
function Skeleton({ className, ...props }: React.ComponentProps<"div">): JSX.Element
// Renders: <div className="bg-accent animate-pulse rounded-md ..." />
export { Skeleton }
```
From components/mobile/KpiCardMobile.tsx (Phase 3 — reference only, NOT modified here):
```typescript
export type KpiTone = 'default' | 'attention';
interface KpiCardMobileProps {
label: string;
value: number | string;
caption?: string;
tone?: KpiTone;
}
export function KpiCardMobile(props: KpiCardMobileProps): JSX.Element
```
Phase comment block convention (Phase 3/4 pattern — apply to both new files):
```typescript
/* ComponentName — phase 05 (FIN-NN).
* Purpose: one-line description.
* Props: ... */
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create FinanceRow component</name>
<files>components/mobile/FinanceRow.tsx</files>
<read_first>
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (sections: "Invoice / Payment List Rows — Stacked Card Rows [D-06]", "Typography", "Color")
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-06)
- components/mobile/KpiCardMobile.tsx (phase comment block convention)
- app/mobile/finance/page.tsx (lines 262303, current invoice and payment row JSX — extract this shape)
</read_first>
<action>
Create `components/mobile/FinanceRow.tsx` as a 'use client' file (matching `KpiCardMobile.tsx` pattern). Pure presentational — no fetch, no state.
Open with the phase comment block per Phase 3/4 convention:
```typescript
'use client';
/* FinanceRow — phase 05 (FIN-02).
* Purpose: 2-line stacked row used by Open Invoices and Recent Payments lists.
* Line 1: primary label (left, text-sm font-semibold truncate) + amount (right, text-sm font-semibold).
* Line 2: secondary metadata (left, text-xs text-muted-foreground) + optional date (right, text-xs text-muted-foreground).
* Pure presentational — parent owns the data.
* Props: see FinanceRowProps. */
import { cn } from '@/lib/utils';
```
Export the props interface and component:
```typescript
export interface FinanceRowProps {
/** Primary label on line 1 (e.g., customer name) */
primary: string;
/** Amount string already formatted (e.g., "$1,234") — caller passes fmt$(value) */
amount: string;
/** Secondary metadata on line 2 (e.g., "#1234 · Due May 1" or just a date) */
secondary?: React.ReactNode;
/** Optional right-side date on line 2 (used by payment rows; invoice rows put date in `secondary`) */
rightSecondary?: React.ReactNode;
/** When true, amount renders in destructive color (overdue invoices). */
amountTone?: 'default' | 'destructive' | 'positive';
}
export function FinanceRow({ primary, amount, secondary, rightSecondary, amountTone = 'default' }: FinanceRowProps) {
const amountClass = cn(
'text-sm font-semibold shrink-0',
amountTone === 'destructive' && 'text-destructive',
amountTone === 'positive' && 'text-emerald-600'
);
return (
<div className="px-4 py-3 flex items-start justify-between gap-2 hover:bg-muted/50 transition-colors">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{primary}</p>
{secondary && <p className="text-xs text-muted-foreground mt-0.5">{secondary}</p>}
</div>
<div className="shrink-0 text-right">
<p className={amountClass}>{amount}</p>
{rightSecondary && <p className="text-xs text-muted-foreground mt-0.5">{rightSecondary}</p>}
</div>
</div>
);
}
```
Hard rules from UI-SPEC (do NOT deviate):
- Line 1 weight: `text-sm font-semibold` on BOTH primary and amount (per Typography table)
- Line 2 weight: `text-xs font-normal text-muted-foreground` (per Typography table)
- Container: `px-4 py-3 flex items-start justify-between gap-2`
- Hover: `hover:bg-muted/50 transition-colors` (per Color §"Secondary surface")
- Amount tones: only `text-destructive` (overdue) and `text-emerald-600` (payment positive) — NO other colors per D-06 "Invoice Status Colors" table
- ONLY two font weights used: `font-normal` (default on `<p>`) and `font-semibold` — NO `font-medium` per D-03
Do NOT add:
- A priority left-stripe (Finance has no priority taxonomy per UI-SPEC §"Invoice / Payment List Rows")
- A rounded card border on the row itself — the parent `divide-y` block owns the border (per UI-SPEC §"Container: divide-y block inside CollapsibleContent")
- Any onClick / Link wrapping — rows are read-only per D-22
Per CLAUDE.md: kebab-case applies to file names broadly; the established convention in `components/mobile/` (Phase 2/3/4) is PascalCase filenames matching the exported component (`KpiCardMobile.tsx`, `TicketFilterStrip.tsx`, etc.). Match that convention — file is `FinanceRow.tsx`.
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceRow|components/mobile/FinanceRow" || echo "OK: no FinanceRow type errors"</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f components/mobile/FinanceRow.tsx`
- Exports: `grep -E "^export (interface FinanceRowProps|function FinanceRow)" components/mobile/FinanceRow.tsx | wc -l` returns `2`
- Phase comment block present: `grep -q "FinanceRow — phase 05" components/mobile/FinanceRow.tsx`
- Uses only the two locked font weights: `grep -E "font-medium|font-bold" components/mobile/FinanceRow.tsx` returns nothing (per D-03; `font-semibold` is allowed, `font-normal` is the default and need not appear literally)
- Hover class present: `grep -q "hover:bg-muted/50" components/mobile/FinanceRow.tsx`
- No priority stripe colors leaked from Phase 4: `grep -E "border-red-500|border-orange-400|border-amber-400|border-slate-300" components/mobile/FinanceRow.tsx` returns nothing
- No onClick / Link / router import: `grep -E "onClick|next/link|useRouter" components/mobile/FinanceRow.tsx` returns nothing
- Container padding matches D-11 / UI-SPEC: `grep -q "px-4 py-3" components/mobile/FinanceRow.tsx`
- TypeScript clean for this file: `npx tsc --noEmit --pretty 2>&1 | grep -c "components/mobile/FinanceRow.tsx"` returns `0`
</acceptance_criteria>
<done>
`FinanceRow.tsx` exists, type-checks, exports `FinanceRow` + `FinanceRowProps`, renders the 2-line shape from UI-SPEC §"Invoice / Payment List Rows" with correct typography (text-sm font-semibold on line 1, text-xs text-muted-foreground on line 2), supports `amountTone` for destructive/positive variants, no priority stripe, no interactive handlers.
</done>
</task>
<task type="auto">
<name>Task 2: Create FinanceSkeleton component</name>
<files>components/mobile/FinanceSkeleton.tsx</files>
<read_first>
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (section: "Skeleton Loading State [D-17]")
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-10, D-11, D-17)
- components/ui/skeleton.tsx (Skeleton primitive)
</read_first>
<action>
Create `components/mobile/FinanceSkeleton.tsx` as a 'use client' file. Pure presentational placeholder matching the final page's spacing.
Open with the phase comment block:
```typescript
'use client';
/* FinanceSkeleton — phase 05 (FIN-01).
* Purpose: initial-load placeholder for /mobile/finance.
* Layout: 4 KPI tile skeletons (2×2 grid) + 1 aging row (3 cells) + 2 list-row skeleton blocks (3 rows each).
* Pure presentational — no props.
* UI-SPEC §"Skeleton Loading State [D-17]". */
import { Skeleton } from '@/components/ui/skeleton';
```
Export a single `FinanceSkeleton` function component (no props):
Top-level wrapper matches page container per D-10: `<div className="px-4 py-4 space-y-6">`
Three blocks inside, separated by the parent `space-y-6`:
1. **KPI grid skeleton**`grid grid-cols-2 gap-3` containing four `<Skeleton className="h-20 rounded-xl" />` blocks. (UI-SPEC §"Skeleton Loading State" item 1: "4 KPI tile skeletons: grid grid-cols-2 gap-3 px-4 pt-4 — each a Skeleton h-20 rounded-xl"; the parent provides px-4 already, drop the inner px-4/pt-4 to avoid double padding.)
2. **Aging row skeleton**`grid grid-cols-3 gap-2` containing three `<Skeleton className="h-16 rounded-xl" />` blocks. (UI-SPEC item 2.)
3. **Two list-row blocks** — render the same 3-row skeleton block twice (one for invoices, one for payments). Each block is a `<div className="space-y-3">` containing 3 rows. Each row matches UI-SPEC item 3:
```tsx
<div className="px-4 py-3 flex justify-between gap-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-16" />
</div>
```
Hard rules from UI-SPEC:
- ONLY use `<Skeleton>` from `@/components/ui/skeleton` — do not roll a custom pulse div per D-17
- Spacing: outer `space-y-6` per D-10, inner block gaps `space-y-3` per D-11, KPI grid `gap-3` per D-11, aging grid `gap-2` per Spacing Scale "sm"
- No labels, no text content — pure shapes
- Heights: `h-20` for KPI tiles, `h-16` for aging cells, `h-4` for row text lines (matches Skeleton item heights from UI-SPEC literally)
- Page-level `px-4 py-4` per D-10 — owned by the wrapper, not nested
Do NOT add:
- A wrapper Card around any block (KPI tiles are skeletons of `KpiCardMobile`'s outer shape, but in skeleton form a plain rounded-xl Skeleton block IS the placeholder per UI-SPEC item 1 — do not add `<Card>`)
- Animation other than what `<Skeleton>` already provides (`animate-pulse` is in the primitive)
- Variants or props — caller does not parameterize this skeleton
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceSkeleton|components/mobile/FinanceSkeleton" || echo "OK: no FinanceSkeleton type errors"</automated>
</verify>
<acceptance_criteria>
- File exists: `test -f components/mobile/FinanceSkeleton.tsx`
- Exports `FinanceSkeleton`: `grep -q "^export function FinanceSkeleton" components/mobile/FinanceSkeleton.tsx`
- Imports Skeleton primitive: `grep -q "from '@/components/ui/skeleton'" components/mobile/FinanceSkeleton.tsx`
- Phase comment block present: `grep -q "FinanceSkeleton — phase 05" components/mobile/FinanceSkeleton.tsx`
- Uses 2×2 KPI grid: `grep -q "grid-cols-2" components/mobile/FinanceSkeleton.tsx`
- Uses 3-cell aging grid: `grep -q "grid-cols-3" components/mobile/FinanceSkeleton.tsx`
- Outer spacing per D-10: `grep -q "space-y-6" components/mobile/FinanceSkeleton.tsx`
- Row spacing per D-11: `grep -q "space-y-3" components/mobile/FinanceSkeleton.tsx`
- Page padding per D-10: `grep -q "px-4 py-4" components/mobile/FinanceSkeleton.tsx`
- Exact KPI height: `grep -q "h-20" components/mobile/FinanceSkeleton.tsx`
- Exact aging height: `grep -q "h-16" components/mobile/FinanceSkeleton.tsx`
- No Card import (per UI-SPEC item 1): `grep -E "from '@/components/ui/card'" components/mobile/FinanceSkeleton.tsx` returns nothing
- No props on the component: `grep -E "function FinanceSkeleton\(\s*\{" components/mobile/FinanceSkeleton.tsx` returns nothing (the regex matches only if props destructuring is present — its absence indicates a no-prop component)
- TypeScript clean for this file: `npx tsc --noEmit --pretty 2>&1 | grep -c "components/mobile/FinanceSkeleton.tsx"` returns `0`
</acceptance_criteria>
<done>
`FinanceSkeleton.tsx` exists, type-checks, exports a no-prop `FinanceSkeleton` function, renders the exact layout from UI-SPEC §"Skeleton Loading State" (2×2 KPI grid + 3-cell aging row + two 3-row list-skeleton blocks) with the locked spacing tokens (D-10/D-11) and the official `Skeleton` primitive.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| (none introduced) | Both new files are pure presentational React components with no I/O, no fetch, no DOM event handlers, no auth surface, no schema change |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Tampering | components/mobile/FinanceRow.tsx props | accept | Caller passes already-formatted strings (`amount`); no innerHTML, no `dangerouslySetInnerHTML`, React JSX text escaping covers XSS. The `secondary` and `rightSecondary` props are typed `React.ReactNode` so the parent (Plan 02) controls what is rendered — risk is identical to existing TicketRow patterns from Phase 4. |
| T-05-02 | Information Disclosure | components/mobile/FinanceSkeleton.tsx | accept | No props, no data flow. Component renders only static class strings and the `Skeleton` primitive — no path for sensitive data to reach DOM. |
</threat_model>
<verification>
Phase-level checks (run after both tasks):
- `npx tsc --noEmit --pretty` — no errors related to either new file
- `grep -l "FinanceRow\|FinanceSkeleton" components/mobile/` — both files indexed
- No edit to any file outside `components/mobile/`: `git diff --name-only HEAD | grep -v "^components/mobile/Finance" | grep -v "^.planning/"` returns nothing (excluding planning artifacts)
</verification>
<success_criteria>
- Both files compile under `npx tsc --noEmit --pretty`
- `FinanceRow` renders the 2-line stacked-row shape from UI-SPEC §"Invoice / Payment List Rows" with correct typography (`text-sm font-semibold` line 1, `text-xs text-muted-foreground` line 2) and supports `amountTone="destructive" | "positive" | "default"`
- `FinanceSkeleton` renders the exact layout from UI-SPEC §"Skeleton Loading State" with `Skeleton` primitive, locked spacing (`px-4 py-4 space-y-6`, `gap-3`, `gap-2`, `space-y-3`), and the locked heights (`h-20` KPI, `h-16` aging, `h-4` row lines)
- No file outside `components/mobile/` is modified
- Both files use the Phase 3/4 phase-comment-block convention
</success_criteria>
<output>
After completion, create `.planning/phases/05-finance-restyle/05-01-SUMMARY.md` per the GSD summary template, including the exported interface signatures Plan 02 will import:
```typescript
import { FinanceRow, type FinanceRowProps } from '@/components/mobile/FinanceRow';
import { FinanceSkeleton } from '@/components/mobile/FinanceSkeleton';
```
</output>

View file

@ -0,0 +1,676 @@
---
phase: 05-finance-restyle
plan: 02
type: execute
wave: 2
depends_on: [05-01]
files_modified:
- app/mobile/finance/page.tsx
autonomous: false
requirements: [FIN-01, FIN-02]
must_haves:
truths:
- "/mobile/finance opens directly to the four KPI tiles (Total AR / Current / Overdue / Paid MTD) rendered as a 2×2 grid using KpiCardMobile (no Finance H1, since the shell HeaderBar owns the brand mark)"
- "Overdue Aging renders as a 3-cell row (130 / 3160 / 60+) with the locked semantic color tones — visible only when summary.overdue_balance > 0"
- "Open Invoices and Recent Payments collapsibles use shadcn Collapsible (not bare buttons), and both lists render via FinanceRow with the 2-line stacked layout"
- "Top AR by Customer renders as a stacked list (no table) — name + invoice count on the left, balance + proportion bar on the right"
- "Monthly revenue renders as a stacked list (no chart) — month → revenue → count rows in a divide-y card"
- "Initial load shows FinanceSkeleton; load failure shows a destructive-tinted retry card; sync errors fire toast.error; sync success fires toast.success"
- "Empty state ('No outstanding AR') renders when summary.total_ar === 0 AND open_invoices.length === 0"
- "No horizontal overflow at 360px viewport width"
artifacts:
- path: "app/mobile/finance/page.tsx"
provides: "Restyled mobile Finance page (FIN-01, FIN-02)"
contains: "KpiCardMobile, FinanceRow, FinanceSkeleton, Collapsible"
key_links:
- from: "app/mobile/finance/page.tsx"
to: "components/mobile/KpiCardMobile.tsx"
via: "import { KpiCardMobile }"
pattern: "from '@/components/mobile/KpiCardMobile'"
- from: "app/mobile/finance/page.tsx"
to: "components/mobile/FinanceRow.tsx"
via: "import { FinanceRow }"
pattern: "from '@/components/mobile/FinanceRow'"
- from: "app/mobile/finance/page.tsx"
to: "components/mobile/FinanceSkeleton.tsx"
via: "import { FinanceSkeleton }"
pattern: "from '@/components/mobile/FinanceSkeleton'"
- from: "app/mobile/finance/page.tsx"
to: "components/ui/collapsible.tsx"
via: "import { Collapsible, CollapsibleTrigger, CollapsibleContent }"
pattern: "from '@/components/ui/collapsible'"
- from: "app/mobile/finance/page.tsx"
to: "/api/mobile/finance"
via: "fetch in load()"
pattern: "fetch\\(['\"]/api/mobile/finance"
---
<objective>
Rewrite `app/mobile/finance/page.tsx` end-to-end to the Phase 5 visual contract:
2×2 KPI grid (KpiCardMobile), 3-cell aging row, two shadcn Collapsibles for invoices
and payments (each rendering FinanceRow rows from Plan 01), Top Customers stacked
list, Monthly Revenue stacked list (no chart), restyled header controls (icon
Refresh + Sync QBO with proper aria-labels), shadcn-styled invoice tab toggle,
FinanceSkeleton loading state, destructive retry card error state, sonner toasts
on sync, and the D-19 empty state.
Purpose: Deliver FIN-01 (Card + typography scale, no overflow at small phones)
and FIN-02 (wide tables → stacked lists, no new sections, no new data sources).
Output: A single rewritten page file consuming the unchanged `/api/mobile/finance`
route. No API change, no schema change, no new state library. The shell HeaderBar
+ BottomNav from Phase 2 remain untouched (D-23).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/05-finance-restyle/05-CONTEXT.md
@.planning/phases/05-finance-restyle/05-UI-SPEC.md
@.planning/phases/05-finance-restyle/05-01-SUMMARY.md
@.planning/phases/04-tickets-restyle/04-02-SUMMARY.md
@CLAUDE.md
@app/mobile/finance/page.tsx
@app/api/mobile/finance/route.ts
@components/mobile/KpiCardMobile.tsx
<interfaces>
<!-- Contracts the executor consumes — do not re-derive from the codebase -->
From components/mobile/KpiCardMobile.tsx:
```typescript
export type KpiTone = 'default' | 'attention';
interface KpiCardMobileProps {
label: string;
value: number | string; // accepts pre-formatted currency string (UI-SPEC note)
caption?: string;
tone?: KpiTone; // 'attention' adds destructive left border
}
export function KpiCardMobile(props: KpiCardMobileProps): JSX.Element
```
From components/mobile/FinanceRow.tsx (created in Plan 01):
```typescript
export interface FinanceRowProps {
primary: string;
amount: string; // pre-formatted (caller passes fmt$())
secondary?: React.ReactNode;
rightSecondary?: React.ReactNode;
amountTone?: 'default' | 'destructive' | 'positive';
}
export function FinanceRow(props: FinanceRowProps): JSX.Element
```
From components/mobile/FinanceSkeleton.tsx (created in Plan 01):
```typescript
export function FinanceSkeleton(): JSX.Element // no props
```
From components/ui/collapsible.tsx (already installed, used in Phase 4):
```typescript
export function Collapsible(props: { open?: boolean; onOpenChange?: (o: boolean) => void; children: ReactNode }): JSX.Element
export function CollapsibleTrigger(props: ComponentProps): JSX.Element // wraps the click target
export function CollapsibleContent(props: ComponentProps): JSX.Element // shows when open
```
From components/ui/card.tsx:
```typescript
export function Card(props: ComponentProps<"div">): JSX.Element // bg-card border rounded-xl py-6 shadow-sm
export function CardContent(props: ComponentProps<"div">): JSX.Element // px-6
```
Note: `Card` has built-in `py-6 px-0` and `CardContent` has `px-6`. For tightly-padded
inline content (the divide-y row lists), prefer a plain `<div className="rounded-xl border overflow-hidden">` wrapper rather than fighting Card's internal padding. Use `<Card>` only where the UI-SPEC explicitly says "<Card>".
From components/ui/button.tsx:
```typescript
export function Button(props: { variant?: "default" | "outline" | "ghost" | ...; size?: "sm" | "default" | "lg"; ... }): JSX.Element
```
FinanceData shape (kept unchanged per D-21 — already inline in the existing page):
```typescript
interface AgingBucket { balance: number; count: number; }
interface FinanceData {
summary: {
total_ar: number; total_ar_count: number;
current_balance: number; current_count: number;
overdue_balance: number; overdue_count: number;
paid_mtd: number; paid_ytd: number;
};
aging: { days_1_30: AgingBucket; days_31_60: AgingBucket; days_60_plus: AgingBucket };
top_customers: { customer_ref_name: string; balance: number; invoice_count: number }[];
open_invoices: { id: string; doc_number: string; txn_date: string; due_date: string; customer_ref_name: string; total_amt: number; balance: number; status: string; days_overdue: number }[];
recent_payments: { id: string; txn_date: string; customer_ref_name: string; total_amt: number }[];
monthly_revenue: { month: string; revenue: number; count: number }[];
}
```
Helpers — preserved unchanged per D-05, D-21:
```typescript
function fmt$(n: number): string // Intl.NumberFormat USD, maximumFractionDigits: 0
function fmtDate(ts: string): string // 'short month day year'
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Rewrite app/mobile/finance/page.tsx to the Phase 5 visual contract</name>
<files>app/mobile/finance/page.tsx</files>
<read_first>
- .planning/phases/05-finance-restyle/05-UI-SPEC.md (full file — every section is load-bearing)
- .planning/phases/05-finance-restyle/05-CONTEXT.md (D-01 through D-23)
- .planning/phases/05-finance-restyle/05-01-SUMMARY.md (FinanceRow + FinanceSkeleton import paths and prop shapes)
- .planning/phases/04-tickets-restyle/04-02-SUMMARY.md (precedent for toast.error in catch blocks D-21, no horizontal-scroll on small viewports)
- app/mobile/finance/page.tsx (current 309-line file being rewritten — preserve fmt$, fmtDate, syncAndRefresh, loadLastSync, FinanceData interface, all useState shapes verbatim)
- components/mobile/KpiCardMobile.tsx (reuse — do NOT modify)
- components/mobile/FinanceRow.tsx (Plan 01 output)
- components/mobile/FinanceSkeleton.tsx (Plan 01 output)
</read_first>
<action>
Open with `'use client';` and the imports below. Use `import type` only where appropriate.
```typescript
'use client';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
RefreshCw,
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronRight,
CloudDownload,
} from 'lucide-react';
import { KpiCardMobile } from '@/components/mobile/KpiCardMobile';
import { FinanceRow } from '@/components/mobile/FinanceRow';
import { FinanceSkeleton } from '@/components/mobile/FinanceSkeleton';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Button } from '@/components/ui/button';
```
**Preserve verbatim** (per D-05, D-20, D-21):
- `interface AgingBucket { balance: number; count: number; }`
- `interface FinanceData { ... }` — the entire inline interface from the current page
- `function fmt$(n: number) { ... }` — same body, zero decimals via maximumFractionDigits: 0
- `function fmtDate(ts: string) { ... }` — same body
- All useState shapes: `data`, `loading`, `error`, `invoicesOpen`, `paymentsOpen`, `tab` (`'open' | 'overdue'`, default `'overdue'` per D-15), `syncing`, `syncMsg`, `lastSync`
- `load()`, `loadLastSync()`, `syncAndRefresh()` poll loop bodies — keep the existing logic; only the surface changes
**Behavioral changes from the existing page** (these are the locked CONTEXT decisions — apply each):
1. **D-23 — drop the page H1** ("Finance" heading at line 112). The shell HeaderBar owns branding. The page opens directly with the KPI grid. Move the `lastSync` timestamp to render as `text-[10px] text-muted-foreground` inline beside the Refresh button, NOT under a heading.
2. **D-14 — header controls row** at the top of the page (above KPI grid):
```tsx
<div className="flex items-center justify-end gap-2 px-4 pt-4">
{lastSync && (
<span className="text-[10px] text-muted-foreground mr-auto">Last sync {lastSync}</span>
)}
<button
onClick={load}
disabled={loading}
aria-label="Refresh finance data"
className="p-3 rounded-full hover:bg-muted/50 transition-colors disabled:opacity-40"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={syncAndRefresh}
disabled={syncing}
aria-label="Sync from QuickBooks"
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-primary text-primary-foreground text-xs font-semibold disabled:opacity-50 hover:bg-primary/90 transition-colors"
>
<CloudDownload className={`h-3.5 w-3.5 ${syncing ? 'animate-pulse' : ''}`} />
{syncing ? 'Syncing…' : 'Sync QBO'}
</button>
</div>
```
Note `p-3` on Refresh meets the 44 px touch target rule from UI-SPEC §"Spacing Scale".
3. **D-17 — sync progress banner** (when `syncMsg` is non-null): render in `bg-muted text-xs text-muted-foreground rounded-xl px-3 py-2 mx-4` with a `RefreshCw h-3 w-3 animate-spin shrink-0` icon. Same copy table as before — the table in UI-SPEC §"Copywriting Contract" governs ("Starting sync…", "Syncing with QuickBooks…", "Sync already in progress — refreshing data…", "Refreshing data…"). When sync completes successfully (after `setSyncMsg(null)`) fire `toast.success("QuickBooks sync complete")` per UI-SPEC. When sync fails (HTTP non-OK that isn't 409, or thrown error in catch block) fire `toast.error("Sync failed — check QBO connection")` per UI-SPEC.
4. **D-01, D-02 — KPI grid** (replaces the existing AR Hero gradient and the separate Collected MTD / Revenue YTD cards):
```tsx
<div className="grid grid-cols-2 gap-3 px-4">
<KpiCardMobile
label="TOTAL AR"
value={fmt$(summary.total_ar)}
caption={`${summary.total_ar_count} open invoices`}
/>
<KpiCardMobile
label="CURRENT"
value={fmt$(summary.current_balance)}
caption={`${summary.current_count} invoices`}
/>
<KpiCardMobile
label="OVERDUE"
value={fmt$(summary.overdue_balance)}
caption={`${summary.overdue_count} invoices`}
tone="attention"
/>
<KpiCardMobile
label="PAID MTD"
value={fmt$(summary.paid_mtd)}
caption={`YTD: ${fmt$(summary.paid_ytd)}`}
/>
</div>
```
The "Revenue YTD" card the existing page rendered separately is folded into the Paid MTD caption per D-02 / UI-SPEC Copywriting Contract row "Paid MTD caption". DELETE the separate AR Hero block (lines 132151) and the Revenue KPIs grid (lines 197213) entirely — they are replaced by the four `KpiCardMobile`s above.
5. **D-08 — aging row** (visible only when `summary.overdue_balance > 0`) — locked colors from UI-SPEC §"Aging Bucket Status Colors":
```tsx
{summary.overdue_balance > 0 && (
<section className="px-4">
<h2 className="text-sm font-semibold mb-2">Overdue Aging</h2>
<div className="grid grid-cols-3 gap-2">
{([
{ label: '130 days', bucket: aging.days_1_30, classes: 'text-amber-600 bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800' },
{ label: '3160 days', bucket: aging.days_31_60, classes: 'text-orange-600 bg-orange-50 dark:bg-orange-950/30 border-orange-200 dark:border-orange-800' },
{ label: '60+ days', bucket: aging.days_60_plus, classes: 'text-destructive bg-destructive/10 border-destructive/30' },
] as const).map(({ label, bucket, classes }) => (
<div key={label} className={`rounded-xl border p-3 ${classes}`}>
<p className="text-sm font-semibold">{fmt$(bucket.balance)}</p>
<p className="text-[10px] mt-0.5">{label}</p>
<p className="text-[10px] font-mono opacity-70">{bucket.count} inv</p>
</div>
))}
</div>
</section>
)}
```
Note the 60+ uses `text-destructive` token, NOT a raw `text-red-*`. Replace the existing `text-yellow-600 bg-yellow-50 ...` and `text-red-600 bg-red-50 ...` classes (current lines 159161) with the locked palette from the UI-SPEC table.
6. **D-07 — Top AR by Customer stacked list** (visible only when `top_customers.length > 0`):
```tsx
{top_customers.length > 0 && (
<section className="px-4">
<h2 className="text-sm font-semibold mb-2">Top AR by Customer</h2>
<div className="rounded-xl border divide-y overflow-hidden">
{top_customers.map((c) => {
const pct = summary.total_ar > 0 ? Math.round((c.balance / summary.total_ar) * 100) : 0;
return (
<div key={c.customer_ref_name} className="px-4 py-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{c.customer_ref_name}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-sm font-semibold">{fmt$(c.balance)}</p>
<div className="w-16 h-1 rounded-full bg-muted overflow-hidden mt-1">
<div className="h-full rounded-full bg-primary/70" style={{ width: `${pct}%` }} />
</div>
</div>
</div>
);
})}
</div>
</section>
)}
```
Per UI-SPEC §"Top Customers List" — same 2-line shape, but with the proportion bar on the right. This row diverges enough from FinanceRow that we keep it inline (FinanceRow has no proportion-bar slot — extending it for one consumer hurts more than it helps). The previous `text-sm font-medium` (line 181) becomes `text-sm font-semibold` per D-03.
7. **D-09 — Monthly revenue stacked list, NO chart** (visible only when `monthly_revenue.length > 0`):
DELETE the entire bar-chart block (current lines 215235 — flex/items-end/h-full visualization). Replace with:
```tsx
{monthly_revenue.length > 0 && (
<section className="px-4">
<h2 className="text-sm font-semibold mb-2">Revenue — last 12 months</h2>
<div className="rounded-xl border divide-y overflow-hidden">
{monthly_revenue.map((m) => {
const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
return (
<div key={m.month} className="px-4 py-3 flex items-center justify-between gap-2">
<p className="text-sm font-semibold">{monthLabel}</p>
<div className="text-right shrink-0">
<p className="text-sm font-semibold">{fmt$(m.revenue)}</p>
<p className="text-xs text-muted-foreground">{m.count} invoice{m.count !== 1 ? 's' : ''}</p>
</div>
</div>
);
})}
</div>
</section>
)}
```
This is the DASH-04 precedent applied to Phase 5 — no recharts on mobile.
8. **D-15, D-16 — Open Invoices Collapsible with shadcn primitive + restyled tab toggle**:
Replace the bare `<button>` collapsible (current lines 238281) with:
```tsx
<section className="px-4">
<Collapsible open={invoicesOpen} onOpenChange={setInvoicesOpen}>
<div className="rounded-xl border overflow-hidden">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Open Invoices</span>
<span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono">{open_invoices.length}</span>
</span>
{invoicesOpen
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex border-t border-b">
{(['overdue', 'open'] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
aria-pressed={tab === t}
className={`flex-1 text-xs py-2 border-b-2 transition-colors ${
tab === t
? 'border-primary text-primary font-semibold'
: 'border-transparent text-muted-foreground font-normal'
}`}
>
{t === 'overdue' ? `Overdue (${overdueInvoices.length})` : `Current (${currentInvoices.length})`}
</button>
))}
</div>
<div className="divide-y max-h-80 overflow-y-auto">
{(tab === 'overdue' ? overdueInvoices : currentInvoices).map((inv) => (
<FinanceRow
key={inv.id}
primary={inv.customer_ref_name}
amount={fmt$(inv.balance)}
amountTone={inv.status === 'Overdue' ? 'destructive' : 'default'}
secondary={
<>
#{inv.doc_number} · Due {fmtDate(inv.due_date)}
{inv.days_overdue > 0 && (
<span className="text-destructive ml-1">({inv.days_overdue}d overdue)</span>
)}
</>
}
/>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</section>
```
Hard rules:
- Tab buttons use ONLY `font-semibold` (active) / `font-normal` (inactive) — NO `font-medium` per D-03
- Replace `text-red-500`/`text-red-600` from existing lines 269/272 with `text-destructive` token per Color contract
- Count badge classes literally `text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono` per UI-SPEC §"Collapsible Section Triggers"
- Tab toggle gets `aria-pressed={tab === t}` per UI-SPEC §"Accessibility"
9. **D-16 — Recent Payments Collapsible**:
```tsx
<section className="px-4">
<Collapsible open={paymentsOpen} onOpenChange={setPaymentsOpen}>
<div className="rounded-xl border overflow-hidden">
<CollapsibleTrigger className="w-full flex items-center justify-between px-4 py-4 hover:bg-muted/50 transition-colors">
<span className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold">Recent Payments</span>
<span className="text-xs text-muted-foreground bg-muted rounded-full px-2 py-1 font-mono">{recent_payments.length}</span>
</span>
{paymentsOpen
? <ChevronDown className="h-4 w-4 text-muted-foreground" />
: <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="divide-y border-t max-h-64 overflow-y-auto">
{recent_payments.length === 0 ? (
<div className="px-4 py-3 text-sm text-muted-foreground"></div>
) : (
recent_payments.map((p) => (
<FinanceRow
key={p.id}
primary={p.customer_ref_name}
amount={fmt$(p.total_amt)}
amountTone="positive"
secondary={fmtDate(p.txn_date)}
/>
))
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</section>
```
Empty payments collapsible body shows a literal em-dash "—" per UI-SPEC Copywriting Contract row "Empty payments (in collapsible)" / D-19.
10. **D-19 — empty state** when `summary.total_ar === 0 && open_invoices.length === 0`. Render in place of the Open Invoices collapsible and the aging section (which is already gated on `overdue_balance > 0`). Suggested:
```tsx
const isEmpty = summary.total_ar === 0 && open_invoices.length === 0;
```
Use `isEmpty` to conditionally render a `<div className="rounded-xl border bg-muted/30 px-4 py-8 mx-4 text-center"><p className="text-sm text-muted-foreground">No outstanding AR</p></div>` instead of the invoices collapsible. KPI grid + Paid MTD KPI still render (they're informative even at zero AR). Top customers and monthly revenue sections hide naturally when their arrays are empty.
11. **D-18 — error state** (initial load failure): replace the current `<div className="p-4 text-sm text-destructive">{error}</div>` with the destructive-tinted retry card from UI-SPEC §"Error State":
```tsx
if (error) return (
<div className="rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-6 text-center mx-4 my-4">
<p className="text-sm font-semibold text-destructive">Failed to load finance data</p>
<p className="text-xs text-muted-foreground mt-1">Check your connection and try again.</p>
<Button variant="outline" size="sm" className="mt-4" onClick={load}>Retry</Button>
</div>
);
```
12. **D-17 — loading state**:
```tsx
if (loading && !data) return <FinanceSkeleton />;
```
Note the `&& !data` — once data is loaded, subsequent Refresh clicks should NOT flash the skeleton (`loading` flips to `true` again briefly during refresh). The Refresh button's spinning icon already indicates progress.
13. **D-10, D-11 — page container spacing**:
Top-level wrapper: `<div className="pb-4 space-y-6">` (no `px-4` on the outer wrapper — each section owns its own `px-4` since the Refresh banner needs to align tightly with section content while the header controls row already has its own `px-4 pt-4`). The `pb-4` provides bottom padding (the shell layout owns the bottom-nav offset). Sections are separated by `space-y-6` per D-10.
14. **D-21 — toast.error in catch blocks** (Phase 4 D-21 precedent):
- In `load()` catch block: keep `setError(String(e))` AND fire `toast.error("Sync failed — check QBO connection")`? No — `load()` is for initial/refresh page load, not sync. Use a generic `toast.error("Failed to refresh finance data")` here.
- In `syncAndRefresh()` catch block: keep `setSyncMsg('Error: …')` AND fire `toast.error("Sync failed — check QBO connection")` per UI-SPEC Copywriting Contract.
- On successful sync completion (after `setSyncMsg(null)` resolves): fire `toast.success("QuickBooks sync complete")`.
**Hard prohibitions** (do NOT introduce):
- `font-medium` anywhere (D-03)
- `font-bold` anywhere — UI-SPEC locks two weights only and `font-semibold` is the heavy one
- `text-red-500`, `text-red-600`, `text-yellow-600`, `bg-red-50`, `bg-yellow-50` etc. — Aging uses the locked `text-amber-*` / `text-orange-*` / `text-destructive` palette per UI-SPEC §"Aging Bucket Status Colors"; everything else uses `text-destructive` token
- `text-green-600` for success amounts — UI-SPEC §"Invoice Status Colors" locks `text-emerald-600` (FinanceRow handles this via `amountTone="positive"`)
- A separate "Revenue YTD" card — folded into Paid MTD caption per D-02
- `recharts` or any chart component — D-09 / DASH-04 precedent
- New API routes, new endpoints, new fetched data sources (D-20, D-21, D-22)
- New top-level `state` libraries — match existing `useState` + `fetch` per CLAUDE.md
- A wrapper around `<main>` (the shell `app/mobile/layout.tsx` from Phase 2 owns it — D-23)
- Any change to `app/api/mobile/finance/route.ts` (D-20)
- A page H1 ("Finance" heading) — UI-SPEC §"Typography" Heading note explicitly removes it
- Cherry-pick lesson from Phase 4: do NOT touch any file outside `app/mobile/finance/page.tsx`. If `tsc --noEmit` surfaces unrelated errors, leave them alone — they belong to other phases.
**Final structure** (top-down ordering):
1. Header controls row (lastSync caption + Refresh + Sync QBO)
2. Sync progress banner (conditional)
3. Empty-state card OR sections 49 below
4. KPI 2×2 grid
5. Aging row (gated on `overdue_balance > 0`)
6. Top AR by Customer (gated on `top_customers.length > 0`)
7. Open Invoices Collapsible
8. Recent Payments Collapsible
9. Monthly Revenue list (gated on `monthly_revenue.length > 0`)
</action>
<verify>
<automated>npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/finance/page\.tsx" || echo "OK: no type errors in finance page"</automated>
</verify>
<acceptance_criteria>
- File compiles: `npx tsc --noEmit --pretty 2>&1 | grep -c "app/mobile/finance/page.tsx"` returns `0`
- Imports KpiCardMobile: `grep -q "from '@/components/mobile/KpiCardMobile'" app/mobile/finance/page.tsx`
- Imports FinanceRow: `grep -q "from '@/components/mobile/FinanceRow'" app/mobile/finance/page.tsx`
- Imports FinanceSkeleton: `grep -q "from '@/components/mobile/FinanceSkeleton'" app/mobile/finance/page.tsx`
- Imports shadcn Collapsible: `grep -q "from '@/components/ui/collapsible'" app/mobile/finance/page.tsx`
- Imports sonner toast: `grep -q "from 'sonner'" app/mobile/finance/page.tsx`
- Renders 4 KpiCardMobile usages: `grep -c "<KpiCardMobile" app/mobile/finance/page.tsx` returns `4`
- At least one KpiCardMobile uses `tone="attention"` (the Overdue tile): `grep -q 'tone="attention"' app/mobile/finance/page.tsx`
- Renders FinanceRow at least twice (invoice + payment lists): `grep -c "<FinanceRow" app/mobile/finance/page.tsx` returns at least `2`
- No `font-medium`: `grep -E "font-medium" app/mobile/finance/page.tsx` returns nothing (D-03)
- No `font-bold`: `grep -E "font-bold" app/mobile/finance/page.tsx` returns nothing (D-03 — semibold is the heavy weight)
- No raw red Tailwind palette in body: `grep -E "text-red-[0-9]+|bg-red-[0-9]+" app/mobile/finance/page.tsx` returns nothing (use `text-destructive` token)
- No raw yellow Tailwind palette: `grep -E "text-yellow-[0-9]+|bg-yellow-[0-9]+" app/mobile/finance/page.tsx` returns nothing (aging uses amber per UI-SPEC)
- No green-600 (replaced by emerald via FinanceRow): `grep -E "text-green-[0-9]+|bg-green-[0-9]+" app/mobile/finance/page.tsx` returns nothing
- Aging uses locked classes: `grep -q "text-amber-600" app/mobile/finance/page.tsx` AND `grep -q "text-orange-600" app/mobile/finance/page.tsx` AND `grep -q "text-destructive" app/mobile/finance/page.tsx`
- No recharts / chart library import: `grep -E "from 'recharts'" app/mobile/finance/page.tsx` returns nothing
- No bar-chart visualization remnants: `grep -E "items-end|h-full group" app/mobile/finance/page.tsx` returns nothing
- No "Finance" page H1: `grep -E "<h1[^>]*>Finance" app/mobile/finance/page.tsx` returns nothing
- Refresh aria-label present: `grep -q 'aria-label="Refresh finance data"' app/mobile/finance/page.tsx`
- Sync QBO aria-label present: `grep -q 'aria-label="Sync from QuickBooks"' app/mobile/finance/page.tsx`
- toast.success used: `grep -q "toast.success" app/mobile/finance/page.tsx`
- toast.error used: `grep -q "toast.error" app/mobile/finance/page.tsx`
- Shadcn Collapsible used (not bare button collapse): `grep -q "<CollapsibleTrigger" app/mobile/finance/page.tsx` AND `grep -q "<CollapsibleContent" app/mobile/finance/page.tsx`
- FinanceData interface and helpers preserved: `grep -q "interface FinanceData" app/mobile/finance/page.tsx` AND `grep -q "function fmt\\$" app/mobile/finance/page.tsx` AND `grep -q "function fmtDate" app/mobile/finance/page.tsx`
- useState shapes preserved (sample): `grep -q "useState<'open' | 'overdue'>('overdue')" app/mobile/finance/page.tsx` (D-15)
- API route untouched: `git diff --name-only HEAD app/api/mobile/finance/route.ts | wc -l` returns `0`
- Layout untouched: `git diff --name-only HEAD app/mobile/layout.tsx | wc -l` returns `0` (D-23)
- KpiCardMobile untouched: `git diff --name-only HEAD components/mobile/KpiCardMobile.tsx | wc -l` returns `0` (D-01 says reuse, not modify)
- Page-level container has `space-y-6` per D-10: `grep -q "space-y-6" app/mobile/finance/page.tsx`
</acceptance_criteria>
<done>
`app/mobile/finance/page.tsx` is rewritten to consume `KpiCardMobile` (4×), `FinanceRow` (≥2×), `FinanceSkeleton`, and shadcn `Collapsible`; the AR Hero gradient block, the standalone Revenue YTD card, and the bar chart are gone; aging uses locked color classes; tab toggle uses `font-semibold`/`font-normal` only; toast.success and toast.error fire on sync outcomes; D-19 empty state renders when `total_ar === 0 && open_invoices.length === 0`; D-18 retry card replaces the inline error; the page passes `npx tsc --noEmit --pretty`; no other file in the repo is modified.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Human verification — visual + interaction sweep</name>
<files>app/mobile/finance/page.tsx (verifying — not modifying)</files>
<action>Human verification only — see <how-to-verify> below for the 14-step checklist. No code changes. Pause execution and wait for the user to confirm the new finance page behaves per spec on a phone-width viewport.</action>
<verify><automated>echo "Manual checkpoint — see resume-signal"</automated></verify>
<done>User confirms all 14 checklist items pass on a phone-width viewport (DevTools iPhone 15 Pro emulation at 393 px and 360 px), or describes precisely which step failed and why.</done>
<what-built>
A complete visual and interaction restyle of `/mobile/finance` per the Phase 5 UI-SPEC.
The shell HeaderBar + BottomNav (Phase 2) are unchanged and frame the page. The page body
is fully rewritten with: 2×2 KpiCardMobile grid (with Overdue showing the destructive
left border), 3-cell aging row using the locked amber/orange/destructive palette,
Top Customers stacked list with proportion bars, two shadcn Collapsibles (Open Invoices,
Recent Payments) rendering FinanceRow rows, Monthly Revenue stacked list (no chart),
restyled Refresh + Sync QBO header controls with aria-labels, FinanceSkeleton initial
load, destructive retry card on load failure, and sonner toasts on sync outcomes.
</what-built>
<how-to-verify>
Run the app: `npm run dev` (port 3100). Sign in if needed. Then on a phone-sized
viewport (DevTools → Device toolbar → iPhone 15 Pro / 393 × 852 — and also test 360px
width) load `http://localhost:3100/mobile/finance` and verify:
1. **Initial load** — FinanceSkeleton renders for the loading window: 2×2 KPI tile
skeletons + 3-cell aging-row skeleton + two 3-row list skeletons. No flash of
unstyled content. (D-17)
2. **No page H1** — there is NO "Finance" heading at the top of the body. The shell
HeaderBar (Wulf mark + Bell + avatar) is the only chrome above the KPI grid.
(UI-SPEC §"Typography" Heading note)
3. **Header controls row** — Refresh icon button (44 px tap target via `p-3`), Sync QBO
button with cloud icon, and `Last sync ...` caption render in a single row aligned
to the right. Refresh has `aria-label="Refresh finance data"` (inspect element to
confirm). Sync QBO has `aria-label="Sync from QuickBooks"`. (D-14)
4. **KPI grid** — 2×2 with Total AR / Current / Overdue (red left-border via
`tone="attention"`) / Paid MTD. The Paid MTD tile shows "YTD: $X,XXX" caption.
Currency renders with no decimals (zero `fmt$`). (D-01, D-02, D-05)
5. **Aging row** — visible only when overdue exists. Three cells with amber / orange /
destructive tones. No horizontal scroll. (D-08)
6. **Top AR by Customer** — stacked list (NOT a table), each row shows customer name,
invoice count, balance, and a small proportion bar. (D-07)
7. **Open Invoices Collapsible** — collapsed by default. Tapping the chevron opens it.
Inside: a tab toggle (Overdue (N) / Current (N)) using border-bottom + primary
color for active. Tap each tab — list filters live, no refetch. Each row shows
customer + amount on line 1, `#docNumber · Due May 1 (3d overdue)` style on line 2
in `text-xs text-muted-foreground`. Overdue amounts render in `text-destructive`.
(D-06, D-15, D-16)
8. **Recent Payments Collapsible** — collapsed by default. Tap to open. Each row shows
customer + amount on line 1 (amount in `text-emerald-600`) and txn date on line 2.
Empty list shows a single em-dash "—" row. (D-06, D-19)
9. **Monthly Revenue** — stacked list, NO chart. Each row: month label on left,
revenue + invoice count on right. No bars. (D-09 / DASH-04 precedent)
10. **Sync flow** — tap "Sync QBO". Banner appears below the controls with copy from
the UI-SPEC Copywriting Contract ("Starting sync…" → "Syncing with QuickBooks…" →
"Refreshing data…"). On success, sonner toast pops "QuickBooks sync complete".
Disconnect from network and tap again — banner disappears, sonner toast pops
"Sync failed — check QBO connection". (D-14, D-17, D-18)
11. **Error state** — temporarily break the API URL in the page (or stop the dev DB)
and reload. The destructive retry card renders inline with "Failed to load
finance data" / "Check your connection and try again." / "Retry" button. Tapping
Retry re-runs `load()`. (D-18) Restore the URL/DB before approving.
12. **Empty state** — only verifiable if your dev data has no outstanding AR. If so,
confirm "No outstanding AR" message renders instead of empty tables. (D-19)
Skippable if data forces invoices to exist.
13. **No horizontal overflow at 360px** — drag DevTools width to 360 px. No section
overflows the viewport. Tabs, aging row, customer rows, list rows all wrap or
truncate gracefully. (D-12)
14. **Bottom nav active tab** — the Finance icon in the bottom nav uses
`text-primary`. (Pre-existing Phase 2 behavior — sanity check it still works.)
</how-to-verify>
<acceptance_criteria>
- All 14 verification steps PASS (or are explicitly waived with reason)
- No console errors in the browser DevTools console
- No TypeScript errors: re-running `npx tsc --noEmit --pretty` is clean
</acceptance_criteria>
<resume-signal>Type "approved" to mark the phase complete, or describe any issues seen and the executor will course-correct before continuing.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → /api/mobile/finance | Authenticated session cookie (no change from existing route — Better Auth + middleware) |
| Browser → /api/qbo/sync | Authenticated session cookie (existing endpoint, unchanged behavior) |
No new boundary is introduced. The page is read-only from the user's perspective and the only state-changing call is `POST /api/qbo/sync` which already exists and is preserved verbatim.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-03 | Information Disclosure | Page rendering of customer names + balances | accept | Same data the existing page already renders. Auth required by middleware to reach `/mobile/*`. No new exposure surface. |
| T-05-04 | Tampering | Tab toggle / collapsible URL state | accept | Tab and collapsible state is `useState` only — NOT URL-synced (D-16). No URL parsing, no untrusted input flowing to render. |
| T-05-05 | Denial of Service | Sync poll loop | accept | Pre-existing 90-second cap on the poll loop is preserved verbatim. No new loop introduced. |
| T-05-06 | Injection (XSS) | FinanceRow `secondary` / `rightSecondary` ReactNode props | mitigate | All values flow as React text nodes (JSX) — no `dangerouslySetInnerHTML` anywhere. Customer names / doc numbers come from the trusted DB layer (no user-controlled writes pass through `qbo_invoices` / `qbo_payments` — these are QBO-sourced). React's escaping covers any future surprise. |
</threat_model>
<verification>
Phase-level checks:
- `npx tsc --noEmit --pretty` — no errors
- Run dev server, smoke-test the human-verify checkpoint (Task 2)
- `git diff --name-only HEAD` shows ONLY `app/mobile/finance/page.tsx` changed (Plan 01's two new files were committed in Wave 1)
- Visual cross-check against `.planning/phases/05-finance-restyle/05-UI-SPEC.md` — Checker Sign-Off section dimensions 16 should pass
</verification>
<success_criteria>
- FIN-01: `/mobile/finance` adopts the new Card and typography scale; no horizontal overflow at 360 px viewport; spacing legible on small phones (verified by checkpoint Task 2)
- FIN-02: Sections that previously rendered wide tables on phone widths now render as stacked lists; no new sections, no new data sources (verified by acceptance grep checks + checkpoint)
- All 23 locked decisions (D-01 through D-23) are observable in the rendered page or in the file's class strings / behavior
- The shell HeaderBar + BottomNav (Phase 2) and the API route (`app/api/mobile/finance/route.ts`) are byte-identical to before this plan ran
- Page passes `npx tsc --noEmit --pretty`
</success_criteria>
<output>
After completion, create `.planning/phases/05-finance-restyle/05-02-SUMMARY.md` per the GSD summary template, including:
- The before/after line counts (current file is 309 lines)
- Decision coverage matrix (D-01 through D-23 → which section in the rewrite delivers each)
- Confirmation that no file outside `app/mobile/finance/page.tsx` was modified in this plan
</output>