Files
SimpleFinanceDash/.planning/phases/04-full-app-design-consistency/04-03-PLAN.md

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-full-app-design-consistency 03 execute 3
04-02
src/pages/BudgetListPage.tsx
src/pages/BudgetDetailPage.tsx
src/i18n/en.json
src/i18n/de.json
true
UI-BUDGETS-01
UI-RESPONSIVE-01
UI-DESIGN-01
truths artifacts key_links
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
path provides contains
src/pages/BudgetListPage.tsx PageShell adoption, locale-aware months, skeleton, i18n labels PageShell
path provides contains
src/pages/BudgetDetailPage.tsx PageShell, semantic tokens, direction-aware diff, group headers, skeleton text-over-budget
path provides contains
src/i18n/en.json Budget month/year dialog labels and group total i18n key budgets.month
path provides contains
src/i18n/de.json German budget translations budgets.month
from to via pattern
src/pages/BudgetDetailPage.tsx semantic CSS tokens text-over-budget / text-on-budget classes text-over-budget|text-on-budget
from to via pattern
src/pages/BudgetListPage.tsx i18n.language Intl.DateTimeFormat locale parameter Intl.DateTimeFormat
from to via pattern
src/pages/BudgetDetailPage.tsx i18n.language Intl.DateTimeFormat locale parameter Intl.DateTimeFormat
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.

<execution_context> @/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md @/home/jlmak/.claude/get-shit-done/templates/summary.md </execution_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 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):

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

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

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)
  )
}
Task 1: Upgrade BudgetListPage with PageShell, locale-aware months, skeleton, and i18n labels src/pages/BudgetListPage.tsx, src/i18n/en.json, src/i18n/de.json **BudgetListPage.tsx changes:**
  1. Import PageShell, Skeleton, useMemo: Add:

    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:

    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:

    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:

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

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

    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:

"month": "Month",
"year": "Year",
"total": "{{label}} Total"

i18n additions (de.json): Add inside the "budgets" object:

"month": "Monat",
"year": "Jahr",
"total": "{{label}} Gesamt"
cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build 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. Task 2: Upgrade BudgetDetailPage with semantic tokens, direction-aware diff, PageShell, group headers, and skeleton src/pages/BudgetDetailPage.tsx **BudgetDetailPage.tsx changes:**
  1. Import additions: Add:

    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:

    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:

    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:

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

    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:

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

    <TableCell colSpan={2} className="font-medium">
      {t(`categories.types.${type}`)} Total
    </TableCell>
    

    Replace with i18n:

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

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

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

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

<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>
After completion, create `.planning/phases/04-full-app-design-consistency/04-03-SUMMARY.md`