wulf-pulse/.planning/phases/05-finance-restyle/05-02-PLAN.md

39 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements must_haves
05-finance-restyle 02 execute 2
05-01
app/mobile/finance/page.tsx
false
FIN-01
FIN-02
truths artifacts key_links
/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
path provides contains
app/mobile/finance/page.tsx Restyled mobile Finance page (FIN-01, FIN-02) KpiCardMobile, FinanceRow, FinanceSkeleton, Collapsible
from to via pattern
app/mobile/finance/page.tsx components/mobile/KpiCardMobile.tsx import { KpiCardMobile } from '@/components/mobile/KpiCardMobile'
from to via pattern
app/mobile/finance/page.tsx components/mobile/FinanceRow.tsx import { FinanceRow } from '@/components/mobile/FinanceRow'
from to via pattern
app/mobile/finance/page.tsx components/mobile/FinanceSkeleton.tsx import { FinanceSkeleton } from '@/components/mobile/FinanceSkeleton'
from to via pattern
app/mobile/finance/page.tsx components/ui/collapsible.tsx import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'
from to via pattern
app/mobile/finance/page.tsx /api/mobile/finance fetch in load() 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).

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

From components/mobile/KpiCardMobile.tsx:

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

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

export function FinanceSkeleton(): JSX.Element  // no props

From components/ui/collapsible.tsx (already installed, used in Phase 4):

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:

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

From components/ui/button.tsx:

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

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:

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

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

    <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 KpiCardMobiles above.

  5. D-08 — aging row (visible only when summary.overdue_balance > 0) — locked colors from UI-SPEC §"Aging Bucket Status Colors":

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

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

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

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

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

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

    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:

    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) npx tsc --noEmit --pretty 2>&1 | grep -E "app/mobile/finance/page.tsx" || echo "OK: no type errors in finance page" <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> 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.

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

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