diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index fe4a8e7..789c6ce 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 3–7 (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 | - | diff --git a/.planning/phases/05-finance-restyle/05-01-PLAN.md b/.planning/phases/05-finance-restyle/05-01-PLAN.md new file mode 100644 index 0000000..63bb80b --- /dev/null +++ b/.planning/phases/05-finance-restyle/05-01-PLAN.md @@ -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'" +--- + + +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. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +From components/ui/skeleton.tsx: +```typescript +function Skeleton({ className, ...props }: React.ComponentProps<"div">): JSX.Element +// Renders:
+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: ... */ +``` + + + + + + + Task 1: Create FinanceRow component + components/mobile/FinanceRow.tsx + + - .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 262–303, current invoice and payment row JSX — extract this shape) + + +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 ( +
+
+

{primary}

+ {secondary &&

{secondary}

} +
+
+

{amount}

+ {rightSecondary &&

{rightSecondary}

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

`) 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`. + + + npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceRow|components/mobile/FinanceRow" || echo "OK: no FinanceRow type errors" + + + - 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` + + + `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. + + + + + Task 2: Create FinanceSkeleton component + components/mobile/FinanceSkeleton.tsx + + - .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) + + +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: `

` + +Three blocks inside, separated by the parent `space-y-6`: + +1. **KPI grid skeleton** — `grid grid-cols-2 gap-3` containing four `` 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 `` 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 `
` containing 3 rows. Each row matches UI-SPEC item 3: + ```tsx +
+ + +
+ ``` + +Hard rules from UI-SPEC: +- ONLY use `` 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 ``) +- Animation other than what `` already provides (`animate-pulse` is in the primitive) +- Variants or props — caller does not parameterize this skeleton + + + npx tsc --noEmit --pretty 2>&1 | grep -E "FinanceSkeleton|components/mobile/FinanceSkeleton" || echo "OK: no FinanceSkeleton type errors" + + + - 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` + + + `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. + + + + + + +## 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. | + + + +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) + + + +- 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 + + + +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'; +``` + diff --git a/.planning/phases/05-finance-restyle/05-02-PLAN.md b/.planning/phases/05-finance-restyle/05-02-PLAN.md new file mode 100644 index 0000000..45eaaa9 --- /dev/null +++ b/.planning/phases/05-finance-restyle/05-02-PLAN.md @@ -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 (1–30 / 31–60 / 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" +--- + + +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). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 `
` wrapper rather than fighting Card's internal padding. Use `` only where the UI-SPEC explicitly says "". + +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' +``` + + + + + + + Task 1: Rewrite app/mobile/finance/page.tsx to the Phase 5 visual contract + app/mobile/finance/page.tsx + + - .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) + + +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 +
+ {lastSync && ( + Last sync {lastSync} + )} + + +
+ ``` + 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 +
+ + + + +
+ ``` + 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 132–151) and the Revenue KPIs grid (lines 197–213) 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 && ( +
+

Overdue Aging

+
+ {([ + { label: '1–30 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: '31–60 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 }) => ( +
+

{fmt$(bucket.balance)}

+

{label}

+

{bucket.count} inv

+
+ ))} +
+
+ )} + ``` + 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 159–161) 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 && ( +
+

Top AR by Customer

+
+ {top_customers.map((c) => { + const pct = summary.total_ar > 0 ? Math.round((c.balance / summary.total_ar) * 100) : 0; + return ( +
+
+

{c.customer_ref_name}

+

+ {c.invoice_count} invoice{c.invoice_count !== 1 ? 's' : ''} +

+
+
+

{fmt$(c.balance)}

+
+
+
+
+
+ ); + })} +
+
+ )} + ``` + 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 215–235 — flex/items-end/h-full visualization). Replace with: + ```tsx + {monthly_revenue.length > 0 && ( +
+

Revenue — last 12 months

+
+ {monthly_revenue.map((m) => { + const monthLabel = new Date(m.month).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + return ( +
+

{monthLabel}

+
+

{fmt$(m.revenue)}

+

{m.count} invoice{m.count !== 1 ? 's' : ''}

+
+
+ ); + })} +
+
+ )} + ``` + 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 ` + ))} +
+
+ {(tab === 'overdue' ? overdueInvoices : currentInvoices).map((inv) => ( + + #{inv.doc_number} · Due {fmtDate(inv.due_date)} + {inv.days_overdue > 0 && ( + ({inv.days_overdue}d overdue) + )} + + } + /> + ))} +
+ +
+ + + ``` + 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 +
+ +
+ + + + Recent Payments + {recent_payments.length} + + {paymentsOpen + ? + : } + + +
+ {recent_payments.length === 0 ? ( +
+ ) : ( + recent_payments.map((p) => ( + + )) + )} +
+
+
+
+
+ ``` + 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 `

No outstanding AR

` 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 `
{error}
` with the destructive-tinted retry card from UI-SPEC §"Error State": + ```tsx + if (error) return ( +
+

Failed to load finance data

+

Check your connection and try again.

+ +
+ ); + ``` + +12. **D-17 — loading state**: + ```tsx + if (loading && !data) return ; + ``` + 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: `
` (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 `
` (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 4–9 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`) + + + npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/finance/page\.tsx" || echo "OK: no type errors in finance page" + + + - 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 "]*>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 "('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` + + + `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. + + + + + Task 2: Human verification — visual + interaction sweep + app/mobile/finance/page.tsx (verifying — not modifying) + Human verification only — see 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. + echo "Manual checkpoint — see resume-signal" + 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. + + 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. + + + 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.) + + + - 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 + + Type "approved" to mark the phase complete, or describe any issues seen and the executor will course-correct before continuing. + + + + + +## 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. | + + + +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 1–6 should pass + + + +- 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` + + + +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 +