411 lines
16 KiB
Markdown
411 lines
16 KiB
Markdown
---
|
|
phase: 01-design-foundation-and-primitives
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 01-01
|
|
files_modified:
|
|
- src/components/shared/PageShell.tsx
|
|
- src/components/dashboard/StatCard.tsx
|
|
- src/components/dashboard/SummaryStrip.tsx
|
|
- src/components/dashboard/DashboardSkeleton.tsx
|
|
- src/pages/DashboardPage.tsx
|
|
autonomous: true
|
|
requirements:
|
|
- UI-DASH-01
|
|
- UI-DESIGN-01
|
|
- UI-RESPONSIVE-01
|
|
|
|
must_haves:
|
|
truths:
|
|
- "PageShell renders a consistent page header with title, optional description, and optional CTA slot"
|
|
- "StatCard renders a KPI card with title, large formatted value, optional semantic color, and optional variance badge with directional icon"
|
|
- "SummaryStrip renders 3 StatCards in a responsive grid (1 col mobile, 2 cols tablet, 3 cols desktop)"
|
|
- "DashboardSkeleton mirrors the real summary card grid and chart card layout with pulse animations"
|
|
- "DashboardPage uses PageShell instead of inline h1 header"
|
|
- "DashboardPage uses SummaryStrip instead of inline SummaryCard components"
|
|
- "DashboardPage shows DashboardSkeleton during loading instead of returning null"
|
|
- "Balance card uses semantic text-on-budget/text-over-budget classes instead of hardcoded text-green-600/text-red-600"
|
|
artifacts:
|
|
- path: "src/components/shared/PageShell.tsx"
|
|
provides: "Consistent page header wrapper"
|
|
exports: ["PageShell"]
|
|
min_lines: 15
|
|
- path: "src/components/dashboard/StatCard.tsx"
|
|
provides: "KPI display card with variance badge"
|
|
exports: ["StatCard"]
|
|
min_lines: 30
|
|
- path: "src/components/dashboard/SummaryStrip.tsx"
|
|
provides: "Responsive row of 3 StatCards"
|
|
exports: ["SummaryStrip"]
|
|
min_lines: 20
|
|
- path: "src/components/dashboard/DashboardSkeleton.tsx"
|
|
provides: "Skeleton loading placeholder for dashboard"
|
|
exports: ["DashboardSkeleton"]
|
|
min_lines: 20
|
|
- path: "src/pages/DashboardPage.tsx"
|
|
provides: "Refactored dashboard page using new components"
|
|
contains: "PageShell"
|
|
key_links:
|
|
- from: "src/components/dashboard/SummaryStrip.tsx"
|
|
to: "src/components/dashboard/StatCard.tsx"
|
|
via: "import and composition"
|
|
pattern: "import.*StatCard"
|
|
- from: "src/pages/DashboardPage.tsx"
|
|
to: "src/components/shared/PageShell.tsx"
|
|
via: "import and wrapping"
|
|
pattern: "import.*PageShell"
|
|
- from: "src/pages/DashboardPage.tsx"
|
|
to: "src/components/dashboard/SummaryStrip.tsx"
|
|
via: "import replacing inline SummaryCard"
|
|
pattern: "import.*SummaryStrip"
|
|
- from: "src/pages/DashboardPage.tsx"
|
|
to: "src/components/dashboard/DashboardSkeleton.tsx"
|
|
via: "import replacing null loading state"
|
|
pattern: "import.*DashboardSkeleton"
|
|
- from: "src/pages/DashboardPage.tsx"
|
|
to: "src/index.css"
|
|
via: "semantic token classes"
|
|
pattern: "text-(on-budget|over-budget)"
|
|
---
|
|
|
|
<objective>
|
|
Build the shared components (PageShell, StatCard, SummaryStrip, DashboardSkeleton) and integrate them into DashboardPage, replacing the inline SummaryCard, null loading state, and hardcoded color classes.
|
|
|
|
Purpose: Deliver the visual foundation components that all subsequent phases consume. After this plan, the dashboard has semantic KPI cards with variance badges, skeleton loading, and a consistent page header pattern ready for reuse across all 9 pages.
|
|
|
|
Output: 4 new component files, refactored DashboardPage.tsx.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/01-design-foundation-and-primitives/01-RESEARCH.md
|
|
@.planning/phases/01-design-foundation-and-primitives/01-01-SUMMARY.md
|
|
|
|
@src/pages/DashboardPage.tsx
|
|
@src/components/ui/card.tsx
|
|
@src/components/ui/badge.tsx
|
|
@src/components/ui/skeleton.tsx
|
|
@src/lib/format.ts
|
|
@src/lib/palette.ts
|
|
@src/lib/types.ts
|
|
@src/i18n/en.json
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|
|
|
From src/lib/types.ts:
|
|
```typescript
|
|
export type CategoryType = "income" | "bill" | "variable_expense" | "debt" | "saving" | "investment"
|
|
```
|
|
|
|
From src/lib/format.ts:
|
|
```typescript
|
|
export function formatCurrency(amount: number, currency?: string): string
|
|
```
|
|
|
|
From src/lib/palette.ts:
|
|
```typescript
|
|
export const categoryColors: Record<CategoryType, string>
|
|
```
|
|
|
|
From src/components/ui/card.tsx:
|
|
```typescript
|
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
|
```
|
|
|
|
From src/components/ui/badge.tsx:
|
|
```typescript
|
|
export { Badge, badgeVariants }
|
|
```
|
|
|
|
From src/components/ui/skeleton.tsx:
|
|
```typescript
|
|
export { Skeleton }
|
|
```
|
|
|
|
From src/hooks/useBudgets.ts:
|
|
```typescript
|
|
export function useBudgets(): { budgets: Budget[], loading: boolean, ... }
|
|
export function useBudgetDetail(id: string): { budget: Budget | null, items: BudgetItem[], loading: boolean }
|
|
```
|
|
|
|
From existing DashboardPage.tsx (lines 45-66) - the SummaryCard being REPLACED:
|
|
```typescript
|
|
interface SummaryCardProps {
|
|
title: string
|
|
value: string
|
|
valueClassName?: string
|
|
}
|
|
function SummaryCard({ title, value, valueClassName }: SummaryCardProps) { ... }
|
|
```
|
|
|
|
CSS tokens available from Plan 01 (src/index.css):
|
|
- `text-on-budget` (maps to --color-on-budget)
|
|
- `text-over-budget` (maps to --color-over-budget)
|
|
- `text-income` (maps to --color-income)
|
|
- `text-destructive` (maps to --color-destructive)
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create PageShell, StatCard, SummaryStrip, and DashboardSkeleton components</name>
|
|
<files>src/components/shared/PageShell.tsx, src/components/dashboard/StatCard.tsx, src/components/dashboard/SummaryStrip.tsx, src/components/dashboard/DashboardSkeleton.tsx</files>
|
|
<action>
|
|
Create 4 new component files. Create directories `src/components/shared/` and `src/components/dashboard/` if they do not exist.
|
|
|
|
**File 1: src/components/shared/PageShell.tsx**
|
|
|
|
```tsx
|
|
interface PageShellProps {
|
|
title: string
|
|
description?: string
|
|
action?: React.ReactNode
|
|
children: React.ReactNode
|
|
}
|
|
|
|
export function PageShell({ title, description, action, children }: PageShellProps) {
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
|
{description && (
|
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
)}
|
|
</div>
|
|
{action && <div className="shrink-0">{action}</div>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
Key decisions:
|
|
- Named export (not default) per convention for shared components
|
|
- `text-2xl font-semibold tracking-tight` matches existing DashboardPage heading
|
|
- `action` is a ReactNode slot, not a button-specific prop
|
|
- No padding baked in -- AppLayout.tsx already provides `p-6`
|
|
- No i18n dependency -- title comes from the caller via `t()` at the page level
|
|
|
|
**File 2: src/components/dashboard/StatCard.tsx**
|
|
|
|
Follow the pattern from research (Pattern 2) exactly. Named export `StatCard`.
|
|
|
|
Props interface:
|
|
```typescript
|
|
interface StatCardProps {
|
|
title: string
|
|
value: string
|
|
valueClassName?: string
|
|
variance?: {
|
|
amount: string
|
|
direction: "up" | "down" | "neutral"
|
|
label: string
|
|
}
|
|
}
|
|
```
|
|
|
|
Implementation:
|
|
- Import `Card`, `CardContent`, `CardHeader`, `CardTitle` from `@/components/ui/card`
|
|
- Import `TrendingUp`, `TrendingDown`, `Minus` from `lucide-react`
|
|
- Import `cn` from `@/lib/utils`
|
|
- Use `text-2xl font-bold tabular-nums tracking-tight` for the value (upgraded from existing `font-semibold` for more visual weight)
|
|
- Variance section renders a directional icon (size-3) + amount text + label in `text-xs text-muted-foreground`
|
|
- Do NOT import Badge -- the variance display uses inline layout, not a badge component
|
|
|
|
**File 3: src/components/dashboard/SummaryStrip.tsx**
|
|
|
|
Follow the pattern from research (Pattern 3). Named export `SummaryStrip`.
|
|
|
|
Props interface:
|
|
```typescript
|
|
interface SummaryStripProps {
|
|
income: { value: string; budgeted: string }
|
|
expenses: { value: string; budgeted: string }
|
|
balance: { value: string; isPositive: boolean }
|
|
t: (key: string) => string
|
|
}
|
|
```
|
|
|
|
Implementation:
|
|
- Import `StatCard` from `./StatCard`
|
|
- Renders a `<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">` with 3 StatCards
|
|
- Income card: `title={t("dashboard.totalIncome")}`, `valueClassName="text-income"`, variance with direction "neutral" and label `t("budgets.budgeted")`
|
|
- Expenses card: `title={t("dashboard.totalExpenses")}`, `valueClassName="text-destructive"`, variance with direction "neutral" and label `t("budgets.budgeted")`
|
|
- Balance card: `title={t("dashboard.availableBalance")}`, `valueClassName={balance.isPositive ? "text-on-budget" : "text-over-budget"}`, no variance prop
|
|
|
|
Note: The `t` function is passed as a prop to keep SummaryStrip as a presentational component that does not call `useTranslation()` internally. The parent (DashboardContent) already has `t` from `useTranslation()`.
|
|
|
|
**File 4: src/components/dashboard/DashboardSkeleton.tsx**
|
|
|
|
Follow the pattern from research (Pattern 4). Named export `DashboardSkeleton`.
|
|
|
|
Implementation:
|
|
- Import `Skeleton` from `@/components/ui/skeleton`
|
|
- Import `Card`, `CardContent`, `CardHeader` from `@/components/ui/card`
|
|
- Renders a `<div className="flex flex-col gap-6">` with:
|
|
1. Summary cards skeleton: `<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">` with 3 skeleton cards matching StatCard layout (Skeleton h-4 w-24 for title, Skeleton h-8 w-32 for value, Skeleton h-3 w-20 for variance)
|
|
2. Chart area skeleton: `<div className="grid gap-6 lg:grid-cols-2">` with 2 skeleton cards (Skeleton h-5 w-40 for chart title, Skeleton h-[240px] w-full rounded-md for chart area)
|
|
|
|
This mirrors the real dashboard grid exactly so there is no layout shift when data loads.
|
|
|
|
All 4 files use named exports. Follow import order convention: React first, third-party, internal types, internal utilities, components.
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build</automated>
|
|
</verify>
|
|
<done>All 4 component files exist, export the correct named exports, follow project conventions, and build passes. PageShell accepts title/description/action/children. StatCard accepts title/value/valueClassName/variance. SummaryStrip renders 3 StatCards in responsive grid with semantic color classes. DashboardSkeleton mirrors the real layout structure.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Integrate new components into DashboardPage</name>
|
|
<files>src/pages/DashboardPage.tsx</files>
|
|
<action>
|
|
Refactor `src/pages/DashboardPage.tsx` to use the new shared components. This is a MODIFY operation -- preserve all existing logic (derived totals, pie chart, progress groups) while replacing the presentation layer.
|
|
|
|
**Changes to make:**
|
|
|
|
1. **Remove the inline SummaryCard component** (lines 45-66). Delete the entire `SummaryCardProps` interface and `SummaryCard` function. These are replaced by `StatCard`/`SummaryStrip`.
|
|
|
|
2. **Add new imports** at the appropriate positions in the import order:
|
|
```typescript
|
|
import { PageShell } from "@/components/shared/PageShell"
|
|
import { SummaryStrip } from "@/components/dashboard/SummaryStrip"
|
|
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
|
|
```
|
|
|
|
3. **Replace loading states with DashboardSkeleton:**
|
|
- In `DashboardContent`: Replace `if (loading) return null` (line 76) with `if (loading) return <DashboardSkeleton />`
|
|
- In `DashboardPage`: Replace `if (loading) return null` (line 291) with:
|
|
```tsx
|
|
if (loading) return (
|
|
<PageShell title={t("dashboard.title")}>
|
|
<DashboardSkeleton />
|
|
</PageShell>
|
|
)
|
|
```
|
|
|
|
4. **Replace hardcoded balance color** (lines 95-98):
|
|
- BEFORE: `const balanceColor = availableBalance >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"`
|
|
- AFTER: `const balanceColor = availableBalance >= 0 ? "text-on-budget" : "text-over-budget"`
|
|
|
|
5. **Replace hardcoded progress bar colors** (lines 219-221):
|
|
- BEFORE: `const barColor = group.overBudget ? "bg-red-500 dark:bg-red-400" : "bg-green-500 dark:bg-green-400"`
|
|
- AFTER: `const barColor = group.overBudget ? "bg-over-budget" : "bg-on-budget"`
|
|
|
|
6. **Replace hardcoded progress text color** (lines 235-239):
|
|
- BEFORE: `group.overBudget ? "text-red-600 dark:text-red-400" : "text-muted-foreground"`
|
|
- AFTER: `group.overBudget ? "text-over-budget" : "text-muted-foreground"`
|
|
|
|
7. **Replace inline summary cards with SummaryStrip** in DashboardContent's return JSX. Replace the `<div className="grid gap-4 sm:grid-cols-3">` block (lines 135-149) with:
|
|
```tsx
|
|
<SummaryStrip
|
|
income={{
|
|
value: formatCurrency(totalIncome, currency),
|
|
budgeted: formatCurrency(
|
|
items.filter((i) => i.category?.type === "income").reduce((sum, i) => sum + i.budgeted_amount, 0),
|
|
currency
|
|
),
|
|
}}
|
|
expenses={{
|
|
value: formatCurrency(totalExpenses, currency),
|
|
budgeted: formatCurrency(
|
|
items.filter((i) => i.category?.type !== "income").reduce((sum, i) => sum + i.budgeted_amount, 0),
|
|
currency
|
|
),
|
|
}}
|
|
balance={{
|
|
value: formatCurrency(availableBalance, currency),
|
|
isPositive: availableBalance >= 0,
|
|
}}
|
|
t={t}
|
|
/>
|
|
```
|
|
|
|
To avoid recomputing budgeted totals inline, derive them alongside the existing totalIncome/totalExpenses calculations:
|
|
```typescript
|
|
const budgetedIncome = items
|
|
.filter((i) => i.category?.type === "income")
|
|
.reduce((sum, i) => sum + i.budgeted_amount, 0)
|
|
|
|
const budgetedExpenses = items
|
|
.filter((i) => i.category?.type !== "income")
|
|
.reduce((sum, i) => sum + i.budgeted_amount, 0)
|
|
```
|
|
|
|
8. **Replace the page header with PageShell** in the `DashboardPage` component's return. Replace:
|
|
```tsx
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold">{t("dashboard.title")}</h1>
|
|
</div>
|
|
{/* content */}
|
|
</div>
|
|
```
|
|
With:
|
|
```tsx
|
|
<PageShell title={t("dashboard.title")}>
|
|
{/* content */}
|
|
</PageShell>
|
|
```
|
|
|
|
**What to preserve:**
|
|
- All imports for Recharts (PieChart, Pie, Cell, ResponsiveContainer, Tooltip)
|
|
- The `EXPENSE_TYPES` constant
|
|
- The `currentMonthStart` helper
|
|
- The `DashboardContent` component structure (budgetId prop, hooks, derived totals, pie chart, progress groups)
|
|
- The `QuickAddPicker` usage
|
|
- The entire pie chart + legend section
|
|
- The entire category progress section (but with updated color classes)
|
|
- The no-budget empty state with Link to /budgets
|
|
|
|
**What to remove:**
|
|
- The `SummaryCardProps` interface and `SummaryCard` function component
|
|
- The hardcoded `text-green-600`, `text-red-600`, `bg-red-500`, `bg-green-500` color classes
|
|
- The `if (loading) return null` patterns (both in DashboardContent and DashboardPage)
|
|
- The inline `<div className="mb-6 flex items-center justify-between">` header
|
|
</action>
|
|
<verify>
|
|
<automated>npm run build && npm run lint</automated>
|
|
</verify>
|
|
<done>DashboardPage imports and uses PageShell, SummaryStrip, and DashboardSkeleton. No more inline SummaryCard component. Loading states show skeleton instead of null. All hardcoded green/red color classes replaced with semantic token classes (text-on-budget, text-over-budget, bg-on-budget, bg-over-budget). Build and lint pass.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. `npm run build && npm run lint` passes
|
|
2. `src/components/shared/PageShell.tsx` exports `PageShell`
|
|
3. `src/components/dashboard/StatCard.tsx` exports `StatCard`
|
|
4. `src/components/dashboard/SummaryStrip.tsx` exports `SummaryStrip` and imports `StatCard`
|
|
5. `src/components/dashboard/DashboardSkeleton.tsx` exports `DashboardSkeleton`
|
|
6. `src/pages/DashboardPage.tsx` imports PageShell, SummaryStrip, DashboardSkeleton
|
|
7. No occurrences of `text-green-600`, `text-red-600`, `bg-red-500`, `bg-green-500` remain in DashboardPage.tsx
|
|
8. No occurrences of `SummaryCard` remain in DashboardPage.tsx
|
|
9. No `return null` for loading states in DashboardPage.tsx
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- All 4 new component files exist and are well-typed
|
|
- DashboardPage uses PageShell for header, SummaryStrip for KPI cards, DashboardSkeleton for loading
|
|
- Zero hardcoded green/red color values in DashboardPage
|
|
- Build and lint pass cleanly
|
|
- Summary cards display in responsive grid (1/2/3 columns by breakpoint)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/01-design-foundation-and-primitives/01-02-SUMMARY.md`
|
|
</output>
|