449 lines
18 KiB
Markdown
449 lines
18 KiB
Markdown
---
|
|
phase: 04-full-app-design-consistency
|
|
plan: 03
|
|
type: execute
|
|
wave: 3
|
|
depends_on: [04-02]
|
|
files_modified:
|
|
- src/pages/BudgetListPage.tsx
|
|
- src/pages/BudgetDetailPage.tsx
|
|
- src/i18n/en.json
|
|
- src/i18n/de.json
|
|
autonomous: true
|
|
requirements: [UI-BUDGETS-01, UI-RESPONSIVE-01, UI-DESIGN-01]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "BudgetList page uses PageShell for header with title and New Budget button"
|
|
- "BudgetList page shows locale-aware month names (German month names when locale is de)"
|
|
- "BudgetList page shows skeleton loading state instead of blank screen"
|
|
- "BudgetList dialog month/year labels are translated (not hardcoded English)"
|
|
- "BudgetDetail page uses PageShell with locale-aware month heading"
|
|
- "BudgetDetail page shows left-border accent group headers matching dashboard style"
|
|
- "BudgetDetail page uses semantic color tokens (text-over-budget/text-on-budget) instead of text-green-600/text-red-600"
|
|
- "BudgetDetail page uses direction-aware diff logic (spending over when actual > budgeted; income/saving/investment over when actual < budgeted)"
|
|
- "BudgetDetail page shows skeleton loading state instead of blank screen"
|
|
- "No hardcoded 'en' locale string remains in any budget page"
|
|
- "Navigating between all pages produces no jarring visual discontinuity"
|
|
artifacts:
|
|
- path: "src/pages/BudgetListPage.tsx"
|
|
provides: "PageShell adoption, locale-aware months, skeleton, i18n labels"
|
|
contains: "PageShell"
|
|
- path: "src/pages/BudgetDetailPage.tsx"
|
|
provides: "PageShell, semantic tokens, direction-aware diff, group headers, skeleton"
|
|
contains: "text-over-budget"
|
|
- path: "src/i18n/en.json"
|
|
provides: "Budget month/year dialog labels and group total i18n key"
|
|
contains: "budgets.month"
|
|
- path: "src/i18n/de.json"
|
|
provides: "German budget translations"
|
|
contains: "budgets.month"
|
|
key_links:
|
|
- from: "src/pages/BudgetDetailPage.tsx"
|
|
to: "semantic CSS tokens"
|
|
via: "text-over-budget / text-on-budget classes"
|
|
pattern: "text-over-budget|text-on-budget"
|
|
- from: "src/pages/BudgetListPage.tsx"
|
|
to: "i18n.language"
|
|
via: "Intl.DateTimeFormat locale parameter"
|
|
pattern: "Intl\\.DateTimeFormat"
|
|
- from: "src/pages/BudgetDetailPage.tsx"
|
|
to: "i18n.language"
|
|
via: "Intl.DateTimeFormat locale parameter"
|
|
pattern: "Intl\\.DateTimeFormat"
|
|
---
|
|
|
|
<objective>
|
|
Upgrade BudgetListPage and BudgetDetailPage with PageShell, semantic color tokens, direction-aware diff logic, locale-aware month formatting, and skeleton loading states.
|
|
|
|
Purpose: These are the most complex pages in the app. BudgetDetailPage currently uses hardcoded `text-green-600`/`text-red-600` color classes that bypass the design token system, a simplified `isIncome` boolean that mishandles saving/investment types, and a hardcoded `"en"` locale for month formatting. BudgetListPage has a hardcoded English MONTHS array. This plan migrates both to the established design system patterns from Phases 1-3.
|
|
|
|
Output: Two fully upgraded budget pages with consistent visual language, correct semantic tokens, and locale-aware formatting.
|
|
</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/04-full-app-design-consistency/04-CONTEXT.md
|
|
@.planning/phases/04-full-app-design-consistency/04-RESEARCH.md
|
|
|
|
<interfaces>
|
|
From 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)
|
|
```
|
|
|
|
From src/components/dashboard/CategorySection.tsx (direction-aware diff logic to replicate):
|
|
```tsx
|
|
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
|
|
|
|
function isSpendingType(type: CategoryType): boolean {
|
|
return SPENDING_TYPES.includes(type)
|
|
}
|
|
|
|
function computeDiff(budgeted: number, actual: number, type: CategoryType): { diff: number; isOver: boolean } {
|
|
if (isSpendingType(type)) {
|
|
return { diff: budgeted - actual, isOver: actual > budgeted }
|
|
}
|
|
return { diff: actual - budgeted, isOver: actual < budgeted }
|
|
}
|
|
```
|
|
|
|
Semantic color classes (from index.css Phase 1):
|
|
- `text-over-budget` -- red, for amounts exceeding budget
|
|
- `text-on-budget` -- green, for amounts within budget
|
|
- `text-muted-foreground` -- neutral, for zero difference
|
|
|
|
Group header pattern (established in Plan 02):
|
|
```tsx
|
|
<div
|
|
className="mb-2 flex items-center gap-3 rounded-sm border-l-4 bg-muted/30 px-3 py-2"
|
|
style={{ borderLeftColor: categoryColors[type] }}
|
|
>
|
|
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
|
|
</div>
|
|
```
|
|
|
|
Locale-aware month formatting pattern:
|
|
```tsx
|
|
const { i18n } = useTranslation()
|
|
const locale = i18n.language
|
|
// Replace hardcoded MONTHS array:
|
|
const monthItems = useMemo(
|
|
() => Array.from({ length: 12 }, (_, i) => ({
|
|
value: i + 1,
|
|
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, i, 1)),
|
|
})),
|
|
[locale]
|
|
)
|
|
// Replace hardcoded "en" in toLocaleDateString:
|
|
function budgetHeading(startDate: string, locale: string): string {
|
|
const [year, month] = startDate.split("-").map(Number)
|
|
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(
|
|
new Date(year ?? 0, (month ?? 1) - 1, 1)
|
|
)
|
|
}
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Upgrade BudgetListPage with PageShell, locale-aware months, skeleton, and i18n labels</name>
|
|
<files>src/pages/BudgetListPage.tsx, src/i18n/en.json, src/i18n/de.json</files>
|
|
<action>
|
|
**BudgetListPage.tsx changes:**
|
|
|
|
1. **Import PageShell, Skeleton, useMemo:** Add:
|
|
```tsx
|
|
import { useState, useMemo } from "react"
|
|
import { PageShell } from "@/components/shared/PageShell"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
```
|
|
|
|
2. **Remove hardcoded MONTHS array:** Delete the entire `const MONTHS = [...]` constant (lines 36-49).
|
|
|
|
3. **Add locale-aware month generation:** Inside the component, after the existing hooks and state, add:
|
|
```tsx
|
|
const { t, i18n } = useTranslation()
|
|
const locale = i18n.language
|
|
|
|
const monthItems = useMemo(
|
|
() =>
|
|
Array.from({ length: 12 }, (_, i) => ({
|
|
value: i + 1,
|
|
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(
|
|
new Date(2000, i, 1)
|
|
),
|
|
})),
|
|
[locale]
|
|
)
|
|
```
|
|
Update the existing `useTranslation()` call to also destructure `i18n`: change `const { t } = useTranslation()` to `const { t, i18n } = useTranslation()`.
|
|
|
|
**Rules of Hooks:** The `useMemo` must be declared BEFORE the `if (loading)` check. Since `useTranslation` is already before it, just place `useMemo` right after the state declarations and before `if (loading)`.
|
|
|
|
4. **Fix budgetLabel to use locale:** Replace the `budgetLabel` helper function to use locale:
|
|
```tsx
|
|
function budgetLabel(budget: Budget, locale: string): string {
|
|
const [year, month] = budget.start_date.split("-").map(Number)
|
|
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(
|
|
new Date(year ?? 0, (month ?? 1) - 1, 1)
|
|
)
|
|
}
|
|
```
|
|
Update all call sites to pass `locale`: `budgetLabel(budget, locale)` and `budgetLabel(result, locale)`.
|
|
|
|
5. **Replace MONTHS usage in dialog:** In the month Select, replace `MONTHS.map((m) =>` with `monthItems.map((m) =>`. The shape is identical (`{ value, label }`).
|
|
|
|
6. **Replace hardcoded "Month" and "Year" labels:** Replace the `<Label>Month</Label>` and `<Label>Year</Label>` in the new budget dialog with:
|
|
```tsx
|
|
<Label>{t("budgets.month")}</Label>
|
|
// and
|
|
<Label>{t("budgets.year")}</Label>
|
|
```
|
|
|
|
7. **Replace header with PageShell:** Remove the `<div className="mb-6 flex items-center justify-between">` header block. Wrap the return in:
|
|
```tsx
|
|
<PageShell
|
|
title={t("budgets.title")}
|
|
action={
|
|
<Button onClick={openDialog} size="sm">
|
|
<Plus className="mr-1 size-4" />
|
|
{t("budgets.newBudget")}
|
|
</Button>
|
|
}
|
|
>
|
|
{/* empty state + table + dialog */}
|
|
</PageShell>
|
|
```
|
|
|
|
8. **Skeleton loading:** Replace `if (loading) return null` with:
|
|
```tsx
|
|
if (loading) return (
|
|
<PageShell title={t("budgets.title")}>
|
|
<div className="space-y-1">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-border">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-4 w-12" />
|
|
<Skeleton className="ml-auto h-4 w-4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</PageShell>
|
|
)
|
|
```
|
|
|
|
**i18n additions (en.json):** Add inside the "budgets" object:
|
|
```json
|
|
"month": "Month",
|
|
"year": "Year",
|
|
"total": "{{label}} Total"
|
|
```
|
|
|
|
**i18n additions (de.json):** Add inside the "budgets" object:
|
|
```json
|
|
"month": "Monat",
|
|
"year": "Jahr",
|
|
"total": "{{label}} Gesamt"
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
|
</verify>
|
|
<done>BudgetListPage uses PageShell, shows locale-aware month names via Intl.DateTimeFormat (no hardcoded English MONTHS array), dialog labels use i18n keys, skeleton replaces null loading state, budgetLabel uses i18n.language locale. Both en.json and de.json have month/year/total keys. Build passes.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Upgrade BudgetDetailPage with semantic tokens, direction-aware diff, PageShell, group headers, and skeleton</name>
|
|
<files>src/pages/BudgetDetailPage.tsx</files>
|
|
<action>
|
|
**BudgetDetailPage.tsx changes:**
|
|
|
|
1. **Import additions:** Add:
|
|
```tsx
|
|
import { cn } from "@/lib/utils"
|
|
import { PageShell } from "@/components/shared/PageShell"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
```
|
|
|
|
2. **Add direction-aware diff logic:** At module level (above the component), add the same SPENDING_TYPES pattern from CategorySection:
|
|
```tsx
|
|
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
|
|
|
|
function isSpendingType(type: CategoryType): boolean {
|
|
return SPENDING_TYPES.includes(type)
|
|
}
|
|
```
|
|
|
|
3. **Rewrite DifferenceCell:** Replace the entire DifferenceCell component. Change its props: remove `isIncome`, add `type: CategoryType`:
|
|
```tsx
|
|
function DifferenceCell({
|
|
budgeted,
|
|
actual,
|
|
currency,
|
|
type,
|
|
}: {
|
|
budgeted: number
|
|
actual: number
|
|
currency: string
|
|
type: CategoryType
|
|
}) {
|
|
const isOver = isSpendingType(type)
|
|
? actual > budgeted
|
|
: actual < budgeted
|
|
const diff = isSpendingType(type)
|
|
? budgeted - actual
|
|
: actual - budgeted
|
|
|
|
return (
|
|
<TableCell
|
|
className={cn(
|
|
"text-right tabular-nums",
|
|
isOver ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
|
|
)}
|
|
>
|
|
{formatCurrency(Math.abs(diff), currency)}
|
|
{diff < 0 ? " over" : ""}
|
|
</TableCell>
|
|
)
|
|
}
|
|
```
|
|
|
|
4. **Update DifferenceCell call sites:** In the grouped.map render:
|
|
- Remove the `const isIncome = type === "income"` line.
|
|
- Change `<DifferenceCell budgeted={...} actual={...} currency={currency} isIncome={isIncome} />` to `<DifferenceCell budgeted={...} actual={...} currency={currency} type={type} />` in BOTH places (per-item row and group footer).
|
|
|
|
5. **Remove TierBadge from BudgetDetailPage:** Per research recommendation, remove the tier column from BudgetDetailPage to reduce visual noise and align with CategorySection display. This is Claude's discretion per CONTEXT.md.
|
|
- Remove the TierBadge component definition from BudgetDetailPage (keep it in TemplatePage where it belongs).
|
|
- Remove the `<TableHead>{t("categories.type")}</TableHead>` column from the table header.
|
|
- Remove the `<TableCell><TierBadge tier={item.item_tier} /></TableCell>` from each table row.
|
|
- Update the TableFooter `colSpan` accordingly: the first footer cell changes from `colSpan={2}` to no colSpan (or `colSpan={1}`), and the last footer cell changes appropriately.
|
|
- Remove the `Badge` import if no longer used elsewhere in this file.
|
|
|
|
6. **Group header upgrade:** Replace the dot+h2 pattern in grouped.map with:
|
|
```tsx
|
|
<div
|
|
className="mb-2 flex items-center gap-3 rounded-sm border-l-4 bg-muted/30 px-3 py-2"
|
|
style={{ borderLeftColor: categoryColors[type] }}
|
|
>
|
|
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
|
|
</div>
|
|
```
|
|
|
|
7. **Fix locale for headingLabel:** Update the `headingLabel` function. Destructure `i18n` from `useTranslation`: change `const { t } = useTranslation()` to `const { t, i18n } = useTranslation()`. Then:
|
|
```tsx
|
|
function headingLabel(): string {
|
|
if (!budget) return ""
|
|
const [year, month] = budget.start_date.split("-").map(Number)
|
|
return new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(
|
|
new Date(year ?? 0, (month ?? 1) - 1, 1)
|
|
)
|
|
}
|
|
```
|
|
|
|
8. **Fix overall totals section:** The overall totals box at the bottom uses hardcoded `text-green-600`/`text-red-600`. Replace with semantic tokens:
|
|
```tsx
|
|
<p
|
|
className={cn(
|
|
"text-lg font-semibold tabular-nums",
|
|
totalBudgeted - totalActual >= 0 ? "text-on-budget" : "text-over-budget"
|
|
)}
|
|
>
|
|
```
|
|
This replaces the inline ternary with `text-green-600 dark:text-green-400` / `text-red-600 dark:text-red-400`.
|
|
|
|
9. **Fix group footer "Total" label:** The group footer currently has hardcoded English ` Total`:
|
|
```tsx
|
|
<TableCell colSpan={2} className="font-medium">
|
|
{t(`categories.types.${type}`)} Total
|
|
</TableCell>
|
|
```
|
|
Replace with i18n:
|
|
```tsx
|
|
<TableCell className="font-medium">
|
|
{t("budgets.total", { label: t(`categories.types.${type}`) })}
|
|
</TableCell>
|
|
```
|
|
The `budgets.total` key was added in Task 1's i18n step: `"total": "{{label}} Total"` / `"total": "{{label}} Gesamt"`.
|
|
|
|
10. **Replace header with PageShell:** Replace the back link + header section. Keep the back link as a child of PageShell:
|
|
```tsx
|
|
<PageShell
|
|
title={headingLabel()}
|
|
action={
|
|
<Button onClick={openAddDialog} size="sm">
|
|
<Plus className="mr-1 size-4" />
|
|
{t("budgets.addItem")}
|
|
</Button>
|
|
}
|
|
>
|
|
<Link
|
|
to="/budgets"
|
|
className="-mt-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
{t("budgets.title")}
|
|
</Link>
|
|
{/* rest of content */}
|
|
</PageShell>
|
|
```
|
|
The `-mt-4` on the back link compensates for PageShell's `gap-6`, pulling it closer to the header.
|
|
|
|
11. **Skeleton loading:** Replace `if (loading) return null` with:
|
|
```tsx
|
|
if (loading) return (
|
|
<PageShell title="">
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-4 w-24" />
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="flex items-center gap-3 rounded-sm border-l-4 border-muted bg-muted/30 px-3 py-2">
|
|
<Skeleton className="h-4 w-28" />
|
|
</div>
|
|
{[1, 2].map((j) => (
|
|
<div key={j} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="ml-auto h-4 w-20" />
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-4 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
<Skeleton className="h-20 w-full rounded-md" />
|
|
</div>
|
|
</PageShell>
|
|
)
|
|
```
|
|
|
|
**IMPORTANT VERIFICATION after changes:** Ensure NO instances of `text-green-600`, `text-red-600`, `text-green-400`, or `text-red-400` remain in BudgetDetailPage.tsx. All color coding must use `text-over-budget`, `text-on-budget`, or `text-muted-foreground`.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build && grep -c "text-green-600\|text-red-600\|text-green-400\|text-red-400" src/pages/BudgetDetailPage.tsx || echo "CLEAN: no hardcoded color classes"</automated>
|
|
</verify>
|
|
<done>BudgetDetailPage uses semantic color tokens (text-over-budget/text-on-budget) with zero instances of text-green-600 or text-red-600. Direction-aware diff logic handles all 6 category types correctly (spending types over when actual > budgeted, income/saving/investment over when actual < budgeted). Left-border accent group headers replace dot headers. Tier badge column removed for cleaner display. Locale-aware month heading. Skeleton loading state. PageShell wraps the page. Overall totals box uses semantic tokens. Group footer total label uses i18n interpolation. Build passes.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `bun run build` compiles without TypeScript errors
|
|
- `bun run lint` passes (or pre-existing errors only)
|
|
- `grep -c "text-green-600\|text-red-600" src/pages/BudgetDetailPage.tsx` returns 0 (semantic tokens only)
|
|
- `grep -c "text-over-budget\|text-on-budget" src/pages/BudgetDetailPage.tsx` returns at least 2
|
|
- `grep -c "return null" src/pages/BudgetListPage.tsx src/pages/BudgetDetailPage.tsx` returns 0 for both
|
|
- `grep -c 'toLocaleDateString("en"' src/pages/BudgetDetailPage.tsx src/pages/BudgetListPage.tsx` returns 0 (no hardcoded English locale)
|
|
- `grep -c "Intl.DateTimeFormat" src/pages/BudgetListPage.tsx src/pages/BudgetDetailPage.tsx` returns at least 1 for each
|
|
- `grep -c "PageShell" src/pages/BudgetListPage.tsx src/pages/BudgetDetailPage.tsx` returns at least 1 for each
|
|
- `grep "budgets.month" src/i18n/en.json src/i18n/de.json` returns matches in both
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- BudgetListPage: PageShell header, locale-aware month names in dialog and table, skeleton loading, i18n month/year labels
|
|
- BudgetDetailPage: PageShell header, semantic color tokens (no hardcoded green/red), direction-aware diff for all 6 category types, left-border accent group headers, no tier column, locale-aware heading, skeleton loading, i18n group total label
|
|
- No hardcoded English locale strings ("en") remain in budget page formatting
|
|
- No hardcoded Tailwind color classes (text-green-600, text-red-600) remain
|
|
- All 9 app pages now use consistent header layout (PageShell or equivalent)
|
|
- German locale shows fully translated text on both pages
|
|
- `bun run build` passes
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-full-app-design-consistency/04-03-SUMMARY.md`
|
|
</output>
|