Compare commits
29 Commits
d29c0cd482
...
Develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 439d0e950d | |||
| 3a771ba7cd | |||
| 1d5e019839 | |||
| 0410d90e02 | |||
| a270deb2db | |||
| 51516cd73d | |||
| 1e61b88628 | |||
| 24d071c1f3 | |||
| 89dd3ded74 | |||
| f166f1ac5e | |||
| ba19c30f07 | |||
| e9497e42a7 | |||
| b15a14dea0 | |||
| 0ff9939789 | |||
| 36d068e0ba | |||
| fbe01b7372 | |||
| c16f3302c8 | |||
| b6a4d3e83d | |||
| c960d80af0 | |||
| b6e34d991e | |||
| 748c7edc65 | |||
| 80929e1865 | |||
| 9a8d13fcfb | |||
| 7d08da20ce | |||
| f30b846f04 | |||
| 21ce6d8230 | |||
| 1a4035bea1 | |||
| 76f7299976 | |||
| 20a314f2a7 |
20
.planning/MILESTONES.md
Normal file
20
.planning/MILESTONES.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Milestones
|
||||||
|
|
||||||
|
## v1.0 UI/UX Overhaul (Shipped: 2026-03-24)
|
||||||
|
|
||||||
|
**Phases completed:** 4 phases, 10 plans, 19 tasks
|
||||||
|
|
||||||
|
**Key accomplishments:**
|
||||||
|
|
||||||
|
- shadcn chart/collapsible primitives with Recharts v3 patch, two-tier OKLCH category colors, semantic budget status tokens, and bilingual dashboard i18n keys
|
||||||
|
- PageShell, StatCard, SummaryStrip, and DashboardSkeleton components with semantic OKLCH color tokens replacing all hardcoded green/red values in DashboardPage
|
||||||
|
- useMonthParam hook and MonthNavigator component for URL-based month selection, plus 10 new chart/navigation i18n keys in EN and DE
|
||||||
|
- Three isolated chart components (expense donut, income vertical bars, spend horizontal bars) using Recharts + ChartContainer with CSS variable theming, active hover, and per-cell over-budget coloring
|
||||||
|
- DashboardPage wired with URL month navigation (useMonthParam), MonthNavigator in PageShell action slot, and a responsive 3-column chart grid (ExpenseDonutChart, IncomeBarChart, SpendBarChart) replacing the old recharts pie + progress bars
|
||||||
|
- Carryover display wired from DashboardPage through SummaryStrip to StatCard; CategorySection and CollapsibleSections built as pure presentational components with direction-aware difference logic and CSS animation tokens
|
||||||
|
- Collapsible per-category sections wired into DashboardContent with direction-aware smart expand defaults, month-navigation state reset via key prop, and updated DashboardSkeleton.
|
||||||
|
- LoginPage and RegisterPage redesigned with muted background, primary-accent card border, favicon logo, subtitle text, and inline SVG OAuth provider icons
|
||||||
|
- PageShell adoption, skeleton loading states, and left-border accent group headers applied to all four CRUD/settings pages (Categories, Template, QuickAdd, Settings)
|
||||||
|
- BudgetListPage and BudgetDetailPage upgraded with PageShell, locale-aware Intl.DateTimeFormat month names, semantic color tokens (text-over-budget/text-on-budget), direction-aware diff for all 6 category types, left-border accent group headers, skeleton loading, and i18n translations for month/year/total labels
|
||||||
|
|
||||||
|
---
|
||||||
@@ -2,11 +2,24 @@
|
|||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
A personal finance dashboard web app that replaces a basic monthly budget spreadsheet. Users set up income, bills, expenses, debt, and savings categories, create monthly budgets from templates, and track budget vs actual spending with visual charts and summaries. Built as a React SPA with Supabase backend.
|
A self-hosted personal budget dashboard that replaces a manual spreadsheet workflow. It tracks monthly budgets with bills, variable expenses, debts, savings, and investments — presented in a dense, visually rich UI with OKLCH pastel colors, three chart types (donut, bar, horizontal bar), collapsible category sections, and consistent design across all 9 pages. Features a smart template system for recurring items, quick-add library for one-off expenses, and auto-generated budgets. Built with Go (backend) + React/TypeScript (frontend) + PostgreSQL.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
Users can see their full monthly financial picture at a glance — income, spending, and what's left — in a visually rich, easy-to-read dashboard.
|
Opening the app should feel like opening a beautifully designed personal spreadsheet — clean pastel colors, clear data layout, approachable and visually delightful. The UI IS the product.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
**Shipped:** v1.0 (UI/UX Overhaul) + prior foundation (auth, CRUD, templates, quick-add)
|
||||||
|
|
||||||
|
The app has a complete visual overhaul:
|
||||||
|
- OKLCH design system with two-tier category colors and semantic budget status tokens
|
||||||
|
- PageShell, StatCard, SummaryStrip shared components for consistent page structure
|
||||||
|
- Three chart types (expense donut, income bar, spend horizontal bar) with CSS variable theming
|
||||||
|
- URL-based month navigation with MonthNavigator dropdown
|
||||||
|
- Collapsible per-category dashboard sections with direction-aware diff and CSS animations
|
||||||
|
- All 9 pages upgraded with consistent design, skeleton loading, and i18n
|
||||||
|
- Auth pages redesigned with brand presence and OAuth icons
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -24,81 +37,86 @@ Users can see their full monthly financial picture at a glance — income, spend
|
|||||||
- ✓ User can set locale and currency in settings — existing
|
- ✓ User can set locale and currency in settings — existing
|
||||||
- ✓ App supports English and German — existing
|
- ✓ App supports English and German — existing
|
||||||
- ✓ Dashboard shows summary cards (income, expenses, balance) — existing
|
- ✓ Dashboard shows summary cards (income, expenses, balance) — existing
|
||||||
- ✓ Dashboard shows expense breakdown pie chart — existing
|
- ✓ UI-DASH-01: Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections — v1.0
|
||||||
- ✓ Dashboard shows category progress bars — existing
|
- ✓ UI-BAR-01: Bar chart comparing income budget vs actual — v1.0
|
||||||
- ✓ UI-BAR-01: Add bar chart comparing income budget vs actual — Phase 2
|
- ✓ UI-HBAR-01: Horizontal bar chart comparing spend budget vs actual by category type — v1.0
|
||||||
- ✓ UI-HBAR-01: Add horizontal bar chart comparing spend budget vs actual by category type — Phase 2
|
- ✓ UI-DONUT-01: Improved donut chart for expense category breakdown with richer styling — v1.0
|
||||||
- ✓ UI-DONUT-01: Improve donut chart for expense category breakdown with richer styling — Phase 2
|
- ✓ UI-COLLAPSE-01: Collapsible inline sections on dashboard for each category group — v1.0
|
||||||
|
- ✓ UI-DESIGN-01: Redesigned all pages with rich, colorful visual style — v1.0
|
||||||
|
- ✓ UI-AUTH-01: Refreshed login and register pages — v1.0
|
||||||
|
- ✓ UI-CATEGORIES-01: Refreshed categories page — v1.0
|
||||||
|
- ✓ UI-TEMPLATE-01: Refreshed template page — v1.0
|
||||||
|
- ✓ UI-BUDGETS-01: Refreshed budget list and budget detail pages — v1.0
|
||||||
|
- ✓ UI-QUICKADD-01: Refreshed quick-add page — v1.0
|
||||||
|
- ✓ UI-SETTINGS-01: Refreshed settings page — v1.0
|
||||||
|
- ✓ UI-RESPONSIVE-01: Desktop-first responsive layout across all pages — v1.0
|
||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
- [ ] UI-DASH-01: Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections (income, bills, expenses, debt, savings) with budget/actual columns
|
<!-- Next milestone scope. TBD. -->
|
||||||
- [ ] UI-COLLAPSE-01: Add collapsible inline sections on dashboard for each category group showing individual line items
|
|
||||||
- [ ] UI-DESIGN-01: Redesign all pages with rich, colorful visual style — consistent design language across the app
|
(No active requirements — next milestone not yet planned)
|
||||||
- [ ] UI-AUTH-01: Refresh login and register pages
|
|
||||||
- [ ] UI-CATEGORIES-01: Refresh categories page
|
|
||||||
- [ ] UI-TEMPLATE-01: Refresh template page
|
|
||||||
- [ ] UI-BUDGETS-01: Refresh budget list and budget detail pages
|
|
||||||
- [ ] UI-QUICKADD-01: Refresh quick-add page
|
|
||||||
- [ ] UI-SETTINGS-01: Refresh settings page
|
|
||||||
- [ ] UI-RESPONSIVE-01: Desktop-first responsive layout across all pages
|
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
- New backend features or data model changes — this milestone is UI only
|
- New backend features or data model changes — UI milestone complete, future milestones may add backend
|
||||||
- Recurring transaction automation — no backend changes
|
- Recurring transaction automation — future feature
|
||||||
- Spending alerts or notifications — no backend changes
|
- Spending alerts or notifications — future feature
|
||||||
- Trend charts over months — future feature, not part of UI overhaul
|
- Trend charts over months — future feature
|
||||||
- Mobile-first optimization — desktop first, basic responsiveness only
|
- Mobile-first optimization — desktop first, basic responsiveness only
|
||||||
|
- CSV/bank import — future feature
|
||||||
|
- Shared/household budgets — future feature
|
||||||
|
- Dark mode — light mode pastel system first
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
- **Existing codebase:** React 19 + Vite 8 + Tailwind CSS 4 + Supabase + TanStack Query + Recharts + shadcn/ui (Radix) + i18next
|
v1.0 (UI/UX Overhaul) shipped a complete visual transformation across all 9 pages. The design system uses OKLCH color tokens with a two-tier approach (darker text colors for WCAG contrast, lighter fills for charts). PageShell provides consistent page headers. Three chart types replace the old pie chart and progress bars. Collapsible category sections give users drill-down capability on the dashboard. All pages have skeleton loading states and full i18n coverage.
|
||||||
- **Data model:** Profiles, Categories (6 types), Templates/TemplateItems, Budgets/BudgetItems, QuickAddItems
|
|
||||||
- **Architecture:** Three-tier SPA — Pages > Hooks > Supabase. TanStack Query for state. No Redux/Zustand.
|
Tech stack: Go 1.25 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui + Recharts + PostgreSQL 16. Package manager: bun. Single Docker image via multi-stage build.
|
||||||
- **Auth:** Supabase auth with RLS policies. Protected/public routes via React Router.
|
|
||||||
- **Current UI state:** Functional but basic. shadcn/ui cards and tables. Single pie chart and progress bars on dashboard. Other pages are simple CRUD forms/tables.
|
|
||||||
- **Reference image:** Monthly budget spreadsheet with income/bills/expenses/debt/savings sections, budget vs actual columns, bar charts, horizontal bars, and donut chart. Rich colors.
|
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **Tech stack**: Must use existing stack (React, Tailwind, Recharts, shadcn/ui) — no new frameworks or major dependencies
|
- **Tech stack**: Keep existing Go + React + shadcn/ui + Tailwind + Recharts stack
|
||||||
- **Backend**: No Supabase schema changes — UI-only modifications
|
- **Design system**: Build on shadcn/ui, customize via CSS variables and Tailwind config
|
||||||
- **Data model**: Existing types and hooks must be preserved — redesign the presentation layer only
|
- **i18n**: All new/changed UI text must have de + en translations
|
||||||
- **i18n**: All new UI text must have translation keys in both en.json and de.json
|
- **Package manager**: Use bun for frontend
|
||||||
|
- **Platform**: Desktop web only — no mobile/tablet optimization
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| UI overhaul only, no backend changes | Keep scope focused, ship faster, reduce risk | Pending |
|
| UI overhaul only, no backend changes | Keep scope focused, ship faster, reduce risk | ✓ Good |
|
||||||
| Desktop-first layout | Primary use case is desktop; basic responsive for mobile | Pending |
|
| Desktop-first layout | Primary use case is desktop; basic responsive for mobile | ✓ Good |
|
||||||
| Rich & colorful visual style | Match the visual density and appeal of the spreadsheet reference | Pending |
|
| Rich & colorful visual style | Match the visual density and appeal of the spreadsheet reference | ✓ Good |
|
||||||
| Hybrid dashboard (summary + collapsible sections) | Best of both: quick overview with drill-down capability inline | Pending |
|
| Hybrid dashboard (summary + collapsible sections) | Best of both: quick overview with drill-down capability inline | ✓ Good |
|
||||||
| All three chart types (bar, horizontal bar, donut) | Comprehensive financial visualization like the reference | Pending |
|
| All three chart types (bar, horizontal bar, donut) | Comprehensive financial visualization like the reference | ✓ Good |
|
||||||
| Refresh all pages, not just dashboard | Consistent design language throughout the app | Pending |
|
| Refresh all pages, not just dashboard | Consistent design language throughout the app | ✓ Good |
|
||||||
| 4-phase roadmap: Foundation > Charts > Collapsibles > Full-app | Build dependency chain from research; design tokens before components, dashboard before other pages | Pending |
|
| 4-phase roadmap: Foundation > Charts > Collapsibles > Full-app | Design tokens before components, dashboard before other pages | ✓ Good |
|
||||||
| URL-based month navigation via useMonthParam | Survives refresh and enables sharing; uses ?month=YYYY-MM search param | Phase 2 |
|
| URL-based month navigation via useMonthParam | Survives refresh and enables sharing; uses ?month=YYYY-MM search param | ✓ Good |
|
||||||
| 3-column responsive chart grid (md:2, lg:3) | Fits donut + 2 bar charts; collapses gracefully on smaller screens | Phase 2 |
|
| 3-column responsive chart grid (md:2, lg:3) | Fits donut + 2 bar charts; collapses gracefully on smaller screens | ✓ Good |
|
||||||
| DashboardContent as inner component pattern | Separates month selection/empty state from data-dependent chart rendering | Phase 2 |
|
| DashboardContent as inner component pattern | Separates month selection/empty state from data-dependent chart rendering | ✓ Good |
|
||||||
|
| Two-tier OKLCH category colors | Darker text (~0.55) for WCAG contrast, lighter fills (~0.65-0.70) for charts | ✓ Good |
|
||||||
|
| CategorySection controlled open/onOpenChange pattern | Parent owns state for smart expand defaults and month-change reset | ✓ Good |
|
||||||
|
| Auth card accent pattern (border-t-4 + favicon logo) | Consistent brand presence without heavy custom design | ✓ Good |
|
||||||
|
| Direction-aware diff for all 6 category types | Spending over when actual > budget; income/saving over when actual < budget | ✓ Good |
|
||||||
|
|
||||||
## Traceability
|
## Evolution
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
This document evolves at phase transitions and milestone boundaries.
|
||||||
|-------------|-------|--------|
|
|
||||||
| UI-DASH-01 | Phase 1, 2, 3 | Pending |
|
**After each phase transition** (via `/gsd:transition`):
|
||||||
| UI-BAR-01 | Phase 2 | Validated |
|
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||||
| UI-HBAR-01 | Phase 2 | Validated |
|
2. Requirements validated? → Move to Validated with phase reference
|
||||||
| UI-DONUT-01 | Phase 2 | Validated |
|
3. New requirements emerged? → Add to Active
|
||||||
| UI-COLLAPSE-01 | Phase 3 | Pending |
|
4. Decisions to log? → Add to Key Decisions
|
||||||
| UI-DESIGN-01 | Phase 1, 4 | Pending |
|
5. "What This Is" still accurate? → Update if drifted
|
||||||
| UI-AUTH-01 | Phase 4 | Pending |
|
|
||||||
| UI-CATEGORIES-01 | Phase 4 | Pending |
|
**After each milestone** (via `/gsd:complete-milestone`):
|
||||||
| UI-TEMPLATE-01 | Phase 4 | Pending |
|
1. Full review of all sections
|
||||||
| UI-BUDGETS-01 | Phase 4 | Pending |
|
2. Core Value check — still the right priority?
|
||||||
| UI-QUICKADD-01 | Phase 4 | Pending |
|
3. Audit Out of Scope — reasons still valid?
|
||||||
| UI-SETTINGS-01 | Phase 4 | Pending |
|
4. Update Context with current state
|
||||||
| UI-RESPONSIVE-01 | Phase 1, 4 | Pending |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-03-16 after Phase 2*
|
*Last updated: 2026-03-24 after v1.0 UI/UX Overhaul milestone completion*
|
||||||
|
|||||||
59
.planning/RETROSPECTIVE.md
Normal file
59
.planning/RETROSPECTIVE.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Project Retrospective
|
||||||
|
|
||||||
|
*A living document updated after each milestone. Lessons feed forward into future planning.*
|
||||||
|
|
||||||
|
## Milestone: v1.0 — UI/UX Overhaul
|
||||||
|
|
||||||
|
**Shipped:** 2026-03-24
|
||||||
|
**Phases:** 4 | **Plans:** 10 | **Tasks:** 19
|
||||||
|
|
||||||
|
### What Was Built
|
||||||
|
- OKLCH design system with two-tier category colors and semantic budget status tokens
|
||||||
|
- Three chart types (expense donut, income bar, spend horizontal bar) with CSS variable theming
|
||||||
|
- URL-based month navigation with MonthNavigator dropdown
|
||||||
|
- Collapsible per-category dashboard sections with direction-aware diff and CSS animations
|
||||||
|
- PageShell, StatCard, SummaryStrip shared components for consistent page structure
|
||||||
|
- All 9 pages upgraded with consistent design, skeleton loading, and full i18n
|
||||||
|
|
||||||
|
### What Worked
|
||||||
|
- 4-phase dependency chain (foundation → charts → collapsibles → full-app) prevented rework
|
||||||
|
- Research-first approach documented Recharts v3 patch and OKLCH patterns before planning
|
||||||
|
- Pure presentational components (CategorySection, charts) made integration straightforward
|
||||||
|
- Two-tier color approach (dark text, light fills) solved WCAG contrast without sacrificing aesthetics
|
||||||
|
|
||||||
|
### What Was Inefficient
|
||||||
|
- Plan 02-01 SUMMARY.md was never written, requiring retroactive artifact creation
|
||||||
|
- Roadmap progress table fell out of sync (phases 3-4 completed but not marked in roadmap)
|
||||||
|
- Divergence between local and remote branches required hard reset — lost v1.1 milestone work
|
||||||
|
|
||||||
|
### Patterns Established
|
||||||
|
- OKLCH two-tier pattern: ~0.55 lightness for text (WCAG), ~0.65-0.70 for chart fills
|
||||||
|
- PageShell + skeleton pattern: every page gets PageShell header and matching skeleton
|
||||||
|
- Direction-aware diff: SPENDING_TYPES array + isSpendingType() covers all 6 category types
|
||||||
|
- Controlled collapsible state: parent owns open/close for smart defaults and month-change reset
|
||||||
|
- Auth card accent: border-t-4 border-t-primary + favicon logo
|
||||||
|
|
||||||
|
### Key Lessons
|
||||||
|
1. Always verify remote state before starting new local work to avoid divergence
|
||||||
|
2. Summary artifacts must be written immediately — retroactive creation loses context
|
||||||
|
3. Roadmap progress tracking should be verified at milestone completion, not assumed correct
|
||||||
|
|
||||||
|
### Cost Observations
|
||||||
|
- Model mix: 100% quality profile (opus executors, sonnet verifiers)
|
||||||
|
- Sessions: ~4-5 across the milestone
|
||||||
|
- Notable: 10 plans across 4 phases completed in ~20 days with minimal rework
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Milestone Trends
|
||||||
|
|
||||||
|
### Process Evolution
|
||||||
|
|
||||||
|
| Milestone | Phases | Plans | Key Change |
|
||||||
|
|-----------|--------|-------|------------|
|
||||||
|
| v1.0 UI/UX | 4 | 10 | First GSD milestone on this project; research-first approach |
|
||||||
|
|
||||||
|
### Top Lessons (Verified Across Milestones)
|
||||||
|
|
||||||
|
1. Research before planning eliminates mid-phase surprises
|
||||||
|
2. Foundation-first phasing (tokens → components → integration) prevents rework
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.0
|
||||||
milestone_name: milestone
|
milestone_name: milestone
|
||||||
status: executing
|
status: planning
|
||||||
stopped_at: Completed 02-03-PLAN.md — Phase 2 complete
|
stopped_at: Completed 04-03-PLAN.md
|
||||||
last_updated: "2026-03-16T13:29:30.468Z"
|
last_updated: "2026-03-24T08:42:34.008Z"
|
||||||
last_activity: 2026-03-16 — Completed 02-01 (Month Navigation and Chart Infrastructure)
|
last_activity: 2026-03-24
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 2
|
completed_phases: 4
|
||||||
total_plans: 5
|
total_plans: 10
|
||||||
completed_plans: 5
|
completed_plans: 10
|
||||||
percent: 80
|
percent: 100
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -25,16 +25,17 @@ See: .planning/PROJECT.md (updated 2026-03-16)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 3 of 4 (Collapsible Dashboard Sections)
|
Phase: 04 of 4 (full app design consistency)
|
||||||
Plan: Not started
|
Plan: Not started
|
||||||
Status: Ready to plan
|
Status: Ready to plan
|
||||||
Last activity: 2026-03-16 — Phase 2 complete, transitioned to Phase 3
|
Last activity: 2026-03-24
|
||||||
|
|
||||||
Progress: [████████████████████] 5/5 plans (100%)
|
Progress: [████████████████████] 5/5 plans (100%)
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 0
|
- Total plans completed: 0
|
||||||
- Average duration: -
|
- Average duration: -
|
||||||
- Total execution time: 0 hours
|
- Total execution time: 0 hours
|
||||||
@@ -46,6 +47,7 @@ Progress: [████████████████████] 5/5 pla
|
|||||||
| - | - | - | - |
|
| - | - | - | - |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
|
|
||||||
- Last 5 plans: -
|
- Last 5 plans: -
|
||||||
- Trend: -
|
- Trend: -
|
||||||
|
|
||||||
@@ -55,6 +57,11 @@ Progress: [████████████████████] 5/5 pla
|
|||||||
| Phase 02 P02 | 2min | 2 tasks | 4 files |
|
| Phase 02 P02 | 2min | 2 tasks | 4 files |
|
||||||
| Phase 02 P01 | 2min | 2 tasks | 4 files |
|
| Phase 02 P01 | 2min | 2 tasks | 4 files |
|
||||||
| Phase 02-dashboard-charts-and-layout P03 | 3min | 2 tasks | 2 files |
|
| Phase 02-dashboard-charts-and-layout P03 | 3min | 2 tasks | 2 files |
|
||||||
|
| Phase 03-collapsible-dashboard-sections P01 | 2min | 2 tasks | 8 files |
|
||||||
|
| Phase 03-collapsible-dashboard-sections P02 | 2min | 1 tasks | 2 files |
|
||||||
|
| Phase 04-full-app-design-consistency P01 | 2min | 2 tasks | 4 files |
|
||||||
|
| Phase 04-full-app-design-consistency P02 | 3min | 2 tasks | 4 files |
|
||||||
|
| Phase 04-full-app-design-consistency P03 | 2min | 2 tasks | 4 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -76,6 +83,17 @@ Recent decisions affecting current work:
|
|||||||
- [Phase 02-03]: useMemo hooks declared before early returns (Rules of Hooks compliance)
|
- [Phase 02-03]: useMemo hooks declared before early returns (Rules of Hooks compliance)
|
||||||
- [Phase 02-03]: QuickAdd button placed below chart grid (SummaryStrip -> charts -> QuickAdd ordering)
|
- [Phase 02-03]: QuickAdd button placed below chart grid (SummaryStrip -> charts -> QuickAdd ordering)
|
||||||
- [Phase 02-03]: Chart grid uses md:grid-cols-2 lg:grid-cols-3 responsive breakpoints
|
- [Phase 02-03]: Chart grid uses md:grid-cols-2 lg:grid-cols-3 responsive breakpoints
|
||||||
|
- [Phase 03-collapsible-dashboard-sections]: CategorySection uses controlled open/onOpenChange pattern — Plan 02 will own open state in DashboardContent
|
||||||
|
- [Phase 03-collapsible-dashboard-sections]: Direction-aware diff: spending types (bill, variable_expense, debt) over when actual > budgeted; income/saving/investment over when actual < budgeted
|
||||||
|
- [Phase 03-collapsible-dashboard-sections]: CollapsibleContent wired to CSS keyframes via data-[state=open]:animate-collapsible-open Tailwind variant
|
||||||
|
- [Phase 03-collapsible-dashboard-sections]: key prop state reset over useEffect: keying DashboardContent by budgetId causes full remount on month change, cleanly resetting openSections without violating react-hooks linter rules
|
||||||
|
- [Phase 04-full-app-design-consistency]: Inline SVG paths used for Google and GitHub icons — avoids dependency on external icon library while keeping icons fully styled
|
||||||
|
- [Phase 04-full-app-design-consistency]: Auth card accent pattern established: border-t-4 border-t-primary shadow-lg on Card with bg-muted/60 background and favicon.svg logo
|
||||||
|
- [Phase 04-full-app-design-consistency]: TemplatePage uses manual PageShell-mirrored layout (flex flex-col gap-6) instead of PageShell directly — preserves inline-editable TemplateName component
|
||||||
|
- [Phase 04-full-app-design-consistency]: SettingsPage CardHeader and CardTitle removed — PageShell provides page-level title, CardContent gets pt-6 to compensate
|
||||||
|
- [Phase 04-full-app-design-consistency]: Direction-aware diff pattern replicated from CategorySection: SPENDING_TYPES array + isSpendingType() covers all 6 category types correctly
|
||||||
|
- [Phase 04-full-app-design-consistency]: TierBadge column removed from BudgetDetailPage to reduce visual noise and align with CategorySection display style
|
||||||
|
- [Phase 04-full-app-design-consistency]: budgets.month/year/total keys added to en.json and de.json — i18next interpolation pattern for translated total labels
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -87,6 +105,6 @@ None yet.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-16
|
Last session: 2026-03-17T15:23:32.629Z
|
||||||
Stopped at: Phase 2 complete, ready to plan Phase 3
|
Stopped at: Completed 04-03-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
|
|
||||||
- [x] **Phase 1: Design Foundation and Primitives** - Install shadcn primitives, extend color tokens, build PageShell and StatCard/SummaryStrip
|
- [x] **Phase 1: Design Foundation and Primitives** - Install shadcn primitives, extend color tokens, build PageShell and StatCard/SummaryStrip
|
||||||
- [x] **Phase 2: Dashboard Charts and Layout** - Build DashboardContent orchestrator with all three chart types and month navigation (completed 2026-03-16)
|
- [x] **Phase 2: Dashboard Charts and Layout** - Build DashboardContent orchestrator with all three chart types and month navigation (completed 2026-03-16)
|
||||||
- [ ] **Phase 3: Collapsible Dashboard Sections** - Add CategorySection with Radix Collapsible, BudgetLineItems, and group totals
|
- [x] **Phase 3: Collapsible Dashboard Sections** - Add CategorySection with Radix Collapsible, BudgetLineItems, and group totals (completed 2026-03-17)
|
||||||
- [ ] **Phase 4: Full-App Design Consistency** - Apply PageShell and established patterns to all 9 non-dashboard pages
|
- [x] **Phase 4: Full-App Design Consistency** - Apply PageShell and established patterns to all 9 non-dashboard pages (completed 2026-03-17)
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
@@ -50,9 +50,9 @@ Plans:
|
|||||||
**Plans**: 3 plans
|
**Plans**: 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 02-01-PLAN.md — Month navigation infrastructure (useMonthParam hook, MonthNavigator, ChartEmptyState, i18n keys)
|
- [x] 02-01-PLAN.md — Month navigation infrastructure (useMonthParam hook, MonthNavigator, ChartEmptyState, i18n keys)
|
||||||
- [ ] 02-02-PLAN.md — Three chart components (ExpenseDonutChart, IncomeBarChart, SpendBarChart)
|
- [x] 02-02-PLAN.md — Three chart components (ExpenseDonutChart, IncomeBarChart, SpendBarChart)
|
||||||
- [ ] 02-03-PLAN.md — Dashboard integration (wire charts + month nav into DashboardPage, update skeleton)
|
- [x] 02-03-PLAN.md — Dashboard integration (wire charts + month nav into DashboardPage, update skeleton)
|
||||||
|
|
||||||
### Phase 3: Collapsible Dashboard Sections
|
### Phase 3: Collapsible Dashboard Sections
|
||||||
**Goal**: Complete the dashboard hybrid view with collapsible per-category sections that show individual line items, group totals, and variance indicators
|
**Goal**: Complete the dashboard hybrid view with collapsible per-category sections that show individual line items, group totals, and variance indicators
|
||||||
@@ -64,10 +64,11 @@ Plans:
|
|||||||
2. Expanding a section reveals a line-item table with individual budget items, and collapsing it hides the table with a smooth CSS animation (no layout shift in charts above)
|
2. Expanding a section reveals a line-item table with individual budget items, and collapsing it hides the table with a smooth CSS animation (no layout shift in charts above)
|
||||||
3. Toggling sections rapidly does not produce `ResizeObserver loop` console errors or visible chart resize jank
|
3. Toggling sections rapidly does not produce `ResizeObserver loop` console errors or visible chart resize jank
|
||||||
4. Carryover amount is visible on the dashboard balance card when the budget has a non-zero carryover
|
4. Carryover amount is visible on the dashboard balance card when the budget has a non-zero carryover
|
||||||
**Plans**: TBD
|
**Plans**: 2 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 03-01: TBD
|
- [x] 03-01-PLAN.md — Build carryover display, CSS animation tokens, i18n keys, CategorySection and CollapsibleSections components
|
||||||
|
- [x] 03-02-PLAN.md — Wire collapsible sections into DashboardContent with smart defaults, update skeleton, verify
|
||||||
|
|
||||||
### Phase 4: Full-App Design Consistency
|
### Phase 4: Full-App Design Consistency
|
||||||
**Goal**: Apply the design system established in Phases 1-3 to every page in the app, delivering a consistent visual experience across all navigation paths
|
**Goal**: Apply the design system established in Phases 1-3 to every page in the app, delivering a consistent visual experience across all navigation paths
|
||||||
@@ -80,11 +81,12 @@ Plans:
|
|||||||
3. Budget Detail page displays category groups with the same color-accented card style and line-item presentation as the dashboard collapsible sections
|
3. Budget Detail page displays category groups with the same color-accented card style and line-item presentation as the dashboard collapsible sections
|
||||||
4. Navigating between any two pages in the app produces no jarring visual discontinuity in layout, color, or typography
|
4. Navigating between any two pages in the app produces no jarring visual discontinuity in layout, color, or typography
|
||||||
5. Switching the app to German locale shows fully translated text on every page — no raw i18n key strings visible anywhere
|
5. Switching the app to German locale shows fully translated text on every page — no raw i18n key strings visible anywhere
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 04-01: TBD
|
- [x] 04-01-PLAN.md — Redesign auth pages (Login, Register) with brand presence, muted background, card accent, OAuth SVG icons
|
||||||
- [ ] 04-02: TBD
|
- [x] 04-02-PLAN.md — Upgrade CRUD pages (Categories, Template, QuickAdd, Settings) with PageShell, skeletons, group header accents
|
||||||
|
- [x] 04-03-PLAN.md — Upgrade budget pages (BudgetList, BudgetDetail) with semantic tokens, direction-aware diff, locale-aware months, skeletons
|
||||||
|
|
||||||
## Requirements Traceability
|
## Requirements Traceability
|
||||||
|
|
||||||
@@ -135,5 +137,5 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4
|
|||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Design Foundation and Primitives | 2/2 | Complete | 2026-03-16 |
|
| 1. Design Foundation and Primitives | 2/2 | Complete | 2026-03-16 |
|
||||||
| 2. Dashboard Charts and Layout | 3/3 | Complete | 2026-03-16 |
|
| 2. Dashboard Charts and Layout | 3/3 | Complete | 2026-03-16 |
|
||||||
| 3. Collapsible Dashboard Sections | 0/TBD | Not started | - |
|
| 3. Collapsible Dashboard Sections | 2/2 | Complete | 2026-03-17 |
|
||||||
| 4. Full-App Design Consistency | 0/TBD | Not started | - |
|
| 4. Full-App Design Consistency | 3/3 | Complete | 2026-03-17 |
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
phase: 02-dashboard-charts-and-layout
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, react-router, i18n, dashboard, charts]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 01-design-foundation-and-primitives
|
||||||
|
provides: PageShell, design tokens, shadcn chart primitive
|
||||||
|
provides:
|
||||||
|
- useMonthParam hook for URL-based month navigation
|
||||||
|
- MonthNavigator component with prev/next arrows and Select dropdown
|
||||||
|
- Chart and month navigation i18n keys (EN + DE)
|
||||||
|
affects: [02-dashboard-charts-and-layout, 03-collapsible-dashboard-sections]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [URL-based state via useSearchParams, locale-aware month formatting]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- src/hooks/useMonthParam.ts
|
||||||
|
- src/components/dashboard/MonthNavigator.tsx
|
||||||
|
modified:
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "useMonthParam preserves other URL params via setSearchParams callback form"
|
||||||
|
- "navigateMonth uses Date constructor for automatic year rollover"
|
||||||
|
- "MonthNavigator accepts t prop but dropdown uses locale-aware Intl formatting"
|
||||||
|
- "ChartEmptyState already existed from Phase 1 — skipped creation, added i18n keys only"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "URL-based month state: useMonthParam hook as single source of truth for month selection"
|
||||||
|
- "Month formatting: Date.toLocaleDateString with month:'long', year:'numeric'"
|
||||||
|
|
||||||
|
requirements-completed: [UI-DASH-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-16
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 02, Plan 01: Month Navigation and Chart Infrastructure Summary
|
||||||
|
|
||||||
|
**useMonthParam hook and MonthNavigator component for URL-based month selection, plus 10 new chart/navigation i18n keys in EN and DE**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~2 min
|
||||||
|
- **Started:** 2026-03-16T12:02:00Z
|
||||||
|
- **Completed:** 2026-03-16T12:03:06Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- useMonthParam hook reads/writes `?month=YYYY-MM` URL param with current-month fallback and year-rollover navigation
|
||||||
|
- MonthNavigator renders prev/next chevron buttons and a Select dropdown of available budget months with locale-aware formatting
|
||||||
|
- 10 new i18n keys added for chart labels, month navigation, and empty states in both EN and DE
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Create useMonthParam hook and MonthNavigator component** - `4481950` (feat)
|
||||||
|
2. **Task 2: Add chart and month navigation i18n keys** - `42bf1f9` (feat)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/hooks/useMonthParam.ts` - URL-based month state hook with navigateMonth for year rollover
|
||||||
|
- `src/components/dashboard/MonthNavigator.tsx` - Prev/next arrows + Select dropdown for month selection
|
||||||
|
- `src/i18n/en.json` - 10 new dashboard chart and navigation keys
|
||||||
|
- `src/i18n/de.json` - Matching German translations
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- ChartEmptyState component already existed from Phase 1 — only i18n keys were added, component creation skipped
|
||||||
|
- useMonthParam uses setSearchParams callback form to preserve other URL params
|
||||||
|
- MonthNavigator uses Date.toLocaleDateString for locale-aware month display
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Already Exists] Skipped ChartEmptyState component creation**
|
||||||
|
- **Found during:** Task 2 (ChartEmptyState and i18n keys)
|
||||||
|
- **Issue:** ChartEmptyState component already existed from Phase 1 setup
|
||||||
|
- **Fix:** Skipped creation, added only the i18n keys
|
||||||
|
- **Verification:** Component exists and exports correctly
|
||||||
|
- **Committed in:** 42bf1f9 (Task 2 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (1 already exists)
|
||||||
|
**Impact on plan:** No scope creep — component existed, only i18n work needed.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
None
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Month navigation infrastructure ready for dashboard integration (Plan 03)
|
||||||
|
- Chart components (Plan 02) can reference i18n keys
|
||||||
|
- All foundational pieces in place for DashboardPage wiring
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 02-dashboard-charts-and-layout*
|
||||||
|
*Completed: 2026-03-16*
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
---
|
||||||
|
phase: 03-collapsible-dashboard-sections
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/index.css
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
- src/components/dashboard/StatCard.tsx
|
||||||
|
- src/components/dashboard/SummaryStrip.tsx
|
||||||
|
- src/pages/DashboardPage.tsx
|
||||||
|
- src/components/dashboard/CategorySection.tsx
|
||||||
|
- src/components/dashboard/CollapsibleSections.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- UI-DASH-01
|
||||||
|
- UI-COLLAPSE-01
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Balance card shows 'Includes $X carryover' subtitle when carryover is non-zero"
|
||||||
|
- "Balance card has no subtitle when carryover is zero"
|
||||||
|
- "Negative carryover displays with red styling"
|
||||||
|
- "CategorySection renders with left border accent, chevron, label, badges, and difference"
|
||||||
|
- "CollapsibleSections renders an ordered list of CategorySection components"
|
||||||
|
- "Collapsible animation tokens are defined in CSS"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/index.css"
|
||||||
|
provides: "Collapsible animation keyframes and tokens"
|
||||||
|
contains: "collapsible-open"
|
||||||
|
- path: "src/i18n/en.json"
|
||||||
|
provides: "Section and carryover i18n keys"
|
||||||
|
contains: "carryoverIncludes"
|
||||||
|
- path: "src/i18n/de.json"
|
||||||
|
provides: "German section and carryover i18n keys"
|
||||||
|
contains: "carryoverIncludes"
|
||||||
|
- path: "src/components/dashboard/StatCard.tsx"
|
||||||
|
provides: "Optional subtitle prop for carryover display"
|
||||||
|
contains: "subtitle"
|
||||||
|
- path: "src/components/dashboard/SummaryStrip.tsx"
|
||||||
|
provides: "Carryover subtitle threading to balance StatCard"
|
||||||
|
contains: "carryoverSubtitle"
|
||||||
|
- path: "src/pages/DashboardPage.tsx"
|
||||||
|
provides: "Carryover subtitle computed and passed to SummaryStrip"
|
||||||
|
contains: "carryoverSubtitle"
|
||||||
|
- path: "src/components/dashboard/CategorySection.tsx"
|
||||||
|
provides: "Collapsible section with header badges and line-item table"
|
||||||
|
exports: ["CategorySection"]
|
||||||
|
- path: "src/components/dashboard/CollapsibleSections.tsx"
|
||||||
|
provides: "Container rendering ordered CategorySection list"
|
||||||
|
exports: ["CollapsibleSections"]
|
||||||
|
key_links:
|
||||||
|
- from: "src/pages/DashboardPage.tsx"
|
||||||
|
to: "src/components/dashboard/SummaryStrip.tsx"
|
||||||
|
via: "carryoverSubtitle prop on balance object"
|
||||||
|
pattern: "carryoverSubtitle.*formatCurrency.*carryover"
|
||||||
|
- from: "src/components/dashboard/SummaryStrip.tsx"
|
||||||
|
to: "src/components/dashboard/StatCard.tsx"
|
||||||
|
via: "subtitle prop"
|
||||||
|
pattern: "subtitle.*carryoverSubtitle"
|
||||||
|
- from: "src/components/dashboard/CollapsibleSections.tsx"
|
||||||
|
to: "src/components/dashboard/CategorySection.tsx"
|
||||||
|
via: "renders CategorySection per group"
|
||||||
|
pattern: "CategorySection"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the carryover display, CSS animation tokens, i18n keys, and the two new collapsible section components (CategorySection + CollapsibleSections) as pure presentational building blocks.
|
||||||
|
|
||||||
|
Purpose: Establish all the foundational pieces that Plan 02 will wire into DashboardContent. Carryover display is self-contained and ships immediately. Section components are built and tested in isolation.
|
||||||
|
Output: StatCard with subtitle, SummaryStrip with carryover, CSS animation tokens, i18n keys, CategorySection component, CollapsibleSections component.
|
||||||
|
</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/03-collapsible-dashboard-sections/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md
|
||||||
|
@.planning/phases/03-collapsible-dashboard-sections/03-VALIDATION.md
|
||||||
|
|
||||||
|
@src/pages/DashboardPage.tsx
|
||||||
|
@src/components/dashboard/StatCard.tsx
|
||||||
|
@src/components/dashboard/SummaryStrip.tsx
|
||||||
|
@src/components/ui/collapsible.tsx
|
||||||
|
@src/components/ui/table.tsx
|
||||||
|
@src/components/ui/badge.tsx
|
||||||
|
@src/lib/palette.ts
|
||||||
|
@src/lib/types.ts
|
||||||
|
@src/lib/format.ts
|
||||||
|
@src/index.css
|
||||||
|
@src/i18n/en.json
|
||||||
|
@src/i18n/de.json
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. -->
|
||||||
|
|
||||||
|
From src/lib/types.ts:
|
||||||
|
```typescript
|
||||||
|
export type CategoryType = "income" | "bill" | "variable_expense" | "debt" | "saving" | "investment"
|
||||||
|
export interface Budget { id: string; carryover_amount: number; currency: string; /* ... */ }
|
||||||
|
export interface BudgetItem { id: string; budgeted_amount: number; actual_amount: number; category?: Category; /* ... */ }
|
||||||
|
export interface Category { id: string; name: string; type: CategoryType; /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/palette.ts:
|
||||||
|
```typescript
|
||||||
|
export const categoryColors: Record<CategoryType, string> // e.g. { income: "var(--color-income)" }
|
||||||
|
export const categoryLabels: Record<CategoryType, { en: string; de: string }>
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/format.ts:
|
||||||
|
```typescript
|
||||||
|
export function formatCurrency(amount: number, currency?: string, locale?: string): string
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/ui/collapsible.tsx:
|
||||||
|
```typescript
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/dashboard/StatCard.tsx (current):
|
||||||
|
```typescript
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
valueClassName?: string
|
||||||
|
variance?: { amount: string; direction: "up" | "down" | "neutral"; label: string }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/dashboard/SummaryStrip.tsx (current):
|
||||||
|
```typescript
|
||||||
|
interface SummaryStripProps {
|
||||||
|
income: { value: string; budgeted: string }
|
||||||
|
expenses: { value: string; budgeted: string }
|
||||||
|
balance: { value: string; isPositive: boolean }
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add CSS animation tokens, i18n keys, and carryover display</name>
|
||||||
|
<files>src/index.css, src/i18n/en.json, src/i18n/de.json, src/components/dashboard/StatCard.tsx, src/components/dashboard/SummaryStrip.tsx, src/pages/DashboardPage.tsx</files>
|
||||||
|
<action>
|
||||||
|
**1. CSS animation tokens (src/index.css):**
|
||||||
|
|
||||||
|
Add to the existing `@theme inline` block, after the `--radius` line:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Collapsible animation */
|
||||||
|
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||||
|
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add after the `@layer base` block:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes collapsible-open {
|
||||||
|
from { height: 0; overflow: hidden; }
|
||||||
|
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-close {
|
||||||
|
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
to { height: 0; overflow: hidden; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. i18n keys (src/i18n/en.json):**
|
||||||
|
|
||||||
|
Add under the `"dashboard"` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Item",
|
||||||
|
"groupTotal": "{{label}} Total"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. i18n keys (src/i18n/de.json):**
|
||||||
|
|
||||||
|
Add under the `"dashboard"` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Posten",
|
||||||
|
"groupTotal": "{{label}} Gesamt"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Inkl. {{amount}} Übertrag"
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. StatCard subtitle prop (src/components/dashboard/StatCard.tsx):**
|
||||||
|
|
||||||
|
Add two optional props to `StatCardProps`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
subtitle?: string // e.g. "Includes EUR 150.00 carryover"
|
||||||
|
subtitleClassName?: string // e.g. "text-over-budget" for negative carryover
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to the destructured props. Render below the value `<p>` and before the variance block:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{subtitle && (
|
||||||
|
<p className={cn("mt-0.5 text-xs text-muted-foreground", subtitleClassName)}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cn` is already imported from `@/lib/utils`.
|
||||||
|
|
||||||
|
**5. SummaryStrip carryover prop (src/components/dashboard/SummaryStrip.tsx):**
|
||||||
|
|
||||||
|
Extend the `balance` prop type:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
balance: {
|
||||||
|
value: string
|
||||||
|
isPositive: boolean
|
||||||
|
carryoverSubtitle?: string // NEW
|
||||||
|
carryoverIsNegative?: boolean // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass to the balance `StatCard`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StatCard
|
||||||
|
title={t("dashboard.availableBalance")}
|
||||||
|
value={balance.value}
|
||||||
|
valueClassName={balance.isPositive ? "text-on-budget" : "text-over-budget"}
|
||||||
|
subtitle={balance.carryoverSubtitle}
|
||||||
|
subtitleClassName={balance.carryoverIsNegative ? "text-over-budget" : undefined}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. DashboardContent carryover pass-through (src/pages/DashboardPage.tsx):**
|
||||||
|
|
||||||
|
In the `DashboardContent` function, after the `availableBalance` computation (line ~125) and before the `return`, compute the carryover subtitle:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const carryover = budget.carryover_amount
|
||||||
|
const carryoverSubtitle = carryover !== 0
|
||||||
|
? t("dashboard.carryoverIncludes", { amount: formatCurrency(Math.abs(carryover), currency) })
|
||||||
|
: undefined
|
||||||
|
const carryoverIsNegative = carryover < 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `SummaryStrip` balance prop:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
balance={{
|
||||||
|
value: formatCurrency(availableBalance, currency),
|
||||||
|
isPositive: availableBalance >= 0,
|
||||||
|
carryoverSubtitle,
|
||||||
|
carryoverIsNegative,
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `t` function used in DashboardContent is from `useTranslation()` — it already supports interpolation.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run lint && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- StatCard accepts optional subtitle/subtitleClassName props and renders subtitle text below value
|
||||||
|
- SummaryStrip accepts carryoverSubtitle/carryoverIsNegative on balance and passes to StatCard
|
||||||
|
- DashboardContent computes carryover subtitle from budget.carryover_amount and passes to SummaryStrip
|
||||||
|
- CSS animation tokens for collapsible-open/close defined in index.css
|
||||||
|
- i18n keys for sections and carryover added to both en.json and de.json
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Build CategorySection and CollapsibleSections components</name>
|
||||||
|
<files>src/components/dashboard/CategorySection.tsx, src/components/dashboard/CollapsibleSections.tsx</files>
|
||||||
|
<action>
|
||||||
|
**1. Create src/components/dashboard/CategorySection.tsx:**
|
||||||
|
|
||||||
|
A pure presentational component. Accepts pre-computed group data, controlled open/onOpenChange, and `t()` for i18n.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CategorySectionProps {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
currency: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation details:
|
||||||
|
|
||||||
|
- Import `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` from `@/components/ui/collapsible`
|
||||||
|
- Import `Table`, `TableBody`, `TableCell`, `TableFooter`, `TableHead`, `TableHeader`, `TableRow` from `@/components/ui/table`
|
||||||
|
- Import `Badge` from `@/components/ui/badge`
|
||||||
|
- Import `ChevronRight` from `lucide-react`
|
||||||
|
- Import `categoryColors` from `@/lib/palette`
|
||||||
|
- Import `formatCurrency` from `@/lib/format`
|
||||||
|
- Import `cn` from `@/lib/utils`
|
||||||
|
- Import `CategoryType`, `BudgetItem` from `@/lib/types`
|
||||||
|
|
||||||
|
**Header (CollapsibleTrigger):**
|
||||||
|
- `<Collapsible open={open} onOpenChange={onOpenChange}>`
|
||||||
|
- Trigger is a `<button>` with `asChild` on CollapsibleTrigger
|
||||||
|
- Button has: `className="group flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 text-left hover:bg-muted/40 transition-colors"`
|
||||||
|
- Inline style: `style={{ borderLeftColor: categoryColors[type] }}`
|
||||||
|
- ChevronRight icon: `className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90"` with `aria-hidden`
|
||||||
|
- Label span: `className="font-medium"` showing `{label}`
|
||||||
|
- Right side (ml-auto flex items-center gap-2):
|
||||||
|
- Badge variant="outline" className="tabular-nums": `{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}`
|
||||||
|
- Badge variant="secondary" className="tabular-nums": `{t("budgets.actual")} {formatCurrency(actual, currency)}`
|
||||||
|
- Difference span with color coding
|
||||||
|
|
||||||
|
**Difference logic (direction-aware per user decision):**
|
||||||
|
- Spending categories (bill, variable_expense, debt): `diff = budgeted - actual`, isOver when `actual > budgeted`
|
||||||
|
- Income/saving/investment: `diff = actual - budgeted`, isOver when `actual < budgeted`
|
||||||
|
- Color: `isOver ? "text-over-budget" : "text-on-budget"`
|
||||||
|
- Display `formatCurrency(Math.abs(diff), currency)`
|
||||||
|
|
||||||
|
**Content (CollapsibleContent):**
|
||||||
|
- `className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close"`
|
||||||
|
- Contains a `<div className="pt-2">` wrapper for spacing
|
||||||
|
- Table with 4 columns per user decision: Item Name, Budgeted, Actual, Difference
|
||||||
|
- TableHeader: use `t("dashboard.sections.itemName")`, `t("budgets.budgeted")`, `t("budgets.actual")`, `t("budgets.difference")` — last 3 columns right-aligned
|
||||||
|
- TableBody: map items, each row:
|
||||||
|
- Name cell: `item.category?.name ?? item.category_id`, `className="font-medium"`
|
||||||
|
- Budgeted cell: `formatCurrency(item.budgeted_amount, currency)`, `className="text-right tabular-nums"`
|
||||||
|
- Actual cell: `formatCurrency(item.actual_amount, currency)`, `className="text-right tabular-nums"`
|
||||||
|
- Difference cell: direction-aware diff calculation (same isIncome logic as header), color-coded, `className="text-right tabular-nums"` with `text-over-budget` when item is over, else `text-muted-foreground`
|
||||||
|
- TableFooter: bold group totals row
|
||||||
|
- First cell: `t("dashboard.sections.groupTotal", { label })`, `className="font-medium"`
|
||||||
|
- Three total cells: budgeted, actual, diff — all `font-medium text-right tabular-nums`
|
||||||
|
- Footer diff uses same color coding as header (isOver ? text-over-budget : text-on-budget)
|
||||||
|
|
||||||
|
**Per-item difference direction awareness:**
|
||||||
|
- Each item's direction depends on its category type (which is `type` for all items in this section since they're pre-grouped)
|
||||||
|
- For spending types: item diff = `item.budgeted_amount - item.actual_amount`, isOver = `item.actual_amount > item.budgeted_amount`
|
||||||
|
- For income/saving/investment: item diff = `item.actual_amount - item.budgeted_amount`, isOver = `item.actual_amount < item.budgeted_amount`
|
||||||
|
|
||||||
|
**2. Create src/components/dashboard/CollapsibleSections.tsx:**
|
||||||
|
|
||||||
|
Container component that renders an ordered list of CategorySection components.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupData {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollapsibleSectionsProps {
|
||||||
|
groups: GroupData[]
|
||||||
|
currency: string
|
||||||
|
openSections: Record<string, boolean>
|
||||||
|
onToggleSection: (type: string, open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Import `CategorySection` from `./CategorySection`
|
||||||
|
- Import `CategoryType`, `BudgetItem` from `@/lib/types`
|
||||||
|
- Render a `<div className="space-y-3">` wrapping `groups.map(...)`
|
||||||
|
- For each group, render `<CategorySection key={group.type} type={group.type} label={group.label} items={group.items} budgeted={group.budgeted} actual={group.actual} currency={currency} open={openSections[group.type] ?? false} onOpenChange={(open) => onToggleSection(group.type, open)} t={t} />`
|
||||||
|
|
||||||
|
This component is thin glue — its purpose is to keep DashboardContent clean and provide a clear interface boundary for Plan 02 to wire into.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run lint && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- CategorySection.tsx exports a presentational collapsible section with header badges, chevron rotation, line-item table with 4 columns, and footer totals
|
||||||
|
- Direction-aware difference logic implemented per user decision (spending: over when actual > budget; income/saving/investment: over when actual < budget)
|
||||||
|
- CollapsibleSections.tsx exports a container that renders ordered CategorySection list with controlled open state
|
||||||
|
- Both components accept t() as prop (presentational pattern)
|
||||||
|
- Lint and build pass
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run lint` passes with no new errors
|
||||||
|
- `bun run build` succeeds (TypeScript compile + Vite bundle)
|
||||||
|
- StatCard renders subtitle when provided, hides when undefined
|
||||||
|
- CSS animation keyframes defined for collapsible-open and collapsible-close
|
||||||
|
- i18n keys present in both en.json and de.json
|
||||||
|
- CategorySection and CollapsibleSections importable from components/dashboard/
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Carryover subtitle flows from DashboardContent through SummaryStrip to StatCard balance card
|
||||||
|
- CategorySection renders correct header layout: left border accent, chevron, label, badges, difference
|
||||||
|
- CategorySection renders correct table: 4 columns, direction-aware coloring, footer totals
|
||||||
|
- CollapsibleSections renders all groups with controlled open state
|
||||||
|
- No TypeScript errors, no lint errors, build succeeds
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-collapsible-dashboard-sections/03-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: 03-collapsible-dashboard-sections
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tailwind, radix-ui, i18n, collapsible, dashboard]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 02-dashboard-charts-and-layout
|
||||||
|
provides: StatCard, SummaryStrip, DashboardPage foundation
|
||||||
|
provides:
|
||||||
|
- CSS animation tokens for collapsible-open/close (index.css)
|
||||||
|
- i18n keys for sections and carryover in en.json and de.json
|
||||||
|
- StatCard with optional subtitle/subtitleClassName props
|
||||||
|
- SummaryStrip with carryoverSubtitle/carryoverIsNegative on balance
|
||||||
|
- DashboardPage carryover subtitle computed and threaded to SummaryStrip
|
||||||
|
- CategorySection presentational collapsible component with header badges and 4-column table
|
||||||
|
- CollapsibleSections container rendering ordered CategorySection list
|
||||||
|
affects:
|
||||||
|
- 03-02 (Plan 02 will wire CollapsibleSections into DashboardContent)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Presentational components accept t() as prop for i18n decoupling"
|
||||||
|
- "Direction-aware difference logic: spending types over when actual > budget; income/saving/investment over when actual < budget"
|
||||||
|
- "Controlled open state pattern: openSections Record<string,boolean> + onToggleSection callback"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- src/components/dashboard/CategorySection.tsx
|
||||||
|
- src/components/dashboard/CollapsibleSections.tsx
|
||||||
|
modified:
|
||||||
|
- src/index.css
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
- src/components/dashboard/StatCard.tsx
|
||||||
|
- src/components/dashboard/SummaryStrip.tsx
|
||||||
|
- src/pages/DashboardPage.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "CategorySection accepts controlled open/onOpenChange for external state management (Plan 02 will own state)"
|
||||||
|
- "Spending types (bill, variable_expense, debt): diff = budgeted - actual, over when actual > budgeted"
|
||||||
|
- "Income/saving/investment: diff = actual - budgeted, over when actual < budgeted"
|
||||||
|
- "CollapsibleContent uses data-[state=open]:animate-collapsible-open Tailwind variant tied to CSS keyframes"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Collapsible animation: data-[state=open]:animate-collapsible-open / data-[state=closed]:animate-collapsible-close"
|
||||||
|
- "Category color accent: borderLeftColor via categoryColors[type] inline style"
|
||||||
|
|
||||||
|
requirements-completed: [UI-DASH-01, UI-COLLAPSE-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 Plan 01: Collapsible Dashboard Sections (Foundations) Summary
|
||||||
|
|
||||||
|
**Carryover display wired from DashboardPage through SummaryStrip to StatCard; CategorySection and CollapsibleSections built as pure presentational components with direction-aware difference logic and CSS animation tokens**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-17T14:05:37Z
|
||||||
|
- **Completed:** 2026-03-17T14:07:32Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 8 (6 modified, 2 created)
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- CSS animation tokens (`collapsible-open` / `collapsible-close`) and keyframes added to `index.css`
|
||||||
|
- i18n keys for `dashboard.sections` and `dashboard.carryoverIncludes` added to both `en.json` and `de.json`
|
||||||
|
- `StatCard` extended with optional `subtitle` / `subtitleClassName` props rendered below the value
|
||||||
|
- `SummaryStrip` balance prop extended with `carryoverSubtitle` / `carryoverIsNegative`; threaded to `StatCard`
|
||||||
|
- `DashboardPage` computes carryover subtitle from `budget.carryover_amount` and passes to `SummaryStrip`
|
||||||
|
- `CategorySection` built: left border accent, chevron rotation, budgeted/actual badges, 4-column line-item table with footer totals, direction-aware color coding
|
||||||
|
- `CollapsibleSections` built: thin container with controlled open state, renders ordered `CategorySection` list
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Add CSS animation tokens, i18n keys, and carryover display** - `21ce6d8` (feat)
|
||||||
|
2. **Task 2: Build CategorySection and CollapsibleSections components** - `f30b846` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see below)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/index.css` - Added collapsible keyframes and animation CSS tokens
|
||||||
|
- `src/i18n/en.json` - Added `dashboard.sections` and `dashboard.carryoverIncludes` keys
|
||||||
|
- `src/i18n/de.json` - Added German equivalents for sections and carryover keys
|
||||||
|
- `src/components/dashboard/StatCard.tsx` - Added optional `subtitle` / `subtitleClassName` props
|
||||||
|
- `src/components/dashboard/SummaryStrip.tsx` - Extended `balance` type with carryover fields
|
||||||
|
- `src/pages/DashboardPage.tsx` - Computed `carryoverSubtitle` / `carryoverIsNegative` and passed to `SummaryStrip`
|
||||||
|
- `src/components/dashboard/CategorySection.tsx` - New: presentational collapsible section component
|
||||||
|
- `src/components/dashboard/CollapsibleSections.tsx` - New: container rendering ordered CategorySection list
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- `CategorySection` uses controlled `open`/`onOpenChange` pattern — Plan 02 will own the open state in `DashboardContent`
|
||||||
|
- Spending types (`bill`, `variable_expense`, `debt`): over-budget when `actual > budgeted`
|
||||||
|
- Income/saving/investment types: over-budget when `actual < budgeted` (under-earning is the "over" condition)
|
||||||
|
- `CollapsibleContent` wired to CSS keyframes via `data-[state=open]:animate-collapsible-open` Tailwind variant
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None. Build passed cleanly. The 6 pre-existing lint errors (MonthNavigator, badge, button, sidebar, useBudgets) were present before this plan and are unchanged — documented in STATE.md as a known concern.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- All presentational building blocks are ready for Plan 02 to wire into `DashboardContent`
|
||||||
|
- `CollapsibleSections` expects: `groups[]`, `currency`, `openSections: Record<string,boolean>`, `onToggleSection`, `t`
|
||||||
|
- Plan 02 needs to: group `items` by `CategoryType`, compute per-group totals, manage `openSections` state in `DashboardContent`
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 03-collapsible-dashboard-sections*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
---
|
||||||
|
phase: 03-collapsible-dashboard-sections
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 03-01
|
||||||
|
files_modified:
|
||||||
|
- src/pages/DashboardPage.tsx
|
||||||
|
- src/components/dashboard/DashboardSkeleton.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- UI-DASH-01
|
||||||
|
- UI-COLLAPSE-01
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Each non-empty category group renders as a collapsible section between charts and QuickAdd"
|
||||||
|
- "Over-budget sections auto-expand on load (direction-aware: spending overspent, income/savings under-earned/saved)"
|
||||||
|
- "On/under-budget sections start collapsed"
|
||||||
|
- "Empty category groups are hidden entirely"
|
||||||
|
- "Expand/collapse state resets when navigating months"
|
||||||
|
- "Toggling sections does not produce ResizeObserver loop errors or chart resize jank"
|
||||||
|
- "Collapsible sections animate open/close smoothly with no flicker on mount"
|
||||||
|
- "DashboardSkeleton mirrors the sections area layout"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/pages/DashboardPage.tsx"
|
||||||
|
provides: "groupedSections useMemo, openSections state, CollapsibleSections rendering"
|
||||||
|
contains: "groupedSections"
|
||||||
|
- path: "src/components/dashboard/DashboardSkeleton.tsx"
|
||||||
|
provides: "Skeleton placeholders for collapsible sections area"
|
||||||
|
contains: "Skeleton"
|
||||||
|
key_links:
|
||||||
|
- from: "src/pages/DashboardPage.tsx"
|
||||||
|
to: "src/components/dashboard/CollapsibleSections.tsx"
|
||||||
|
via: "renders CollapsibleSections with grouped data and open state"
|
||||||
|
pattern: "CollapsibleSections.*groups.*openSections"
|
||||||
|
- from: "src/pages/DashboardPage.tsx"
|
||||||
|
to: "useBudgetDetail items"
|
||||||
|
via: "groupedSections useMemo derives groups from items"
|
||||||
|
pattern: "groupedSections.*useMemo.*items\\.filter"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire the collapsible sections into DashboardContent with smart expand/collapse defaults, month-navigation state reset, and chart isolation. Update DashboardSkeleton.
|
||||||
|
|
||||||
|
Purpose: Complete the dashboard hybrid view by integrating the CategorySection/CollapsibleSections components built in Plan 01 into the live dashboard page with all the required state management.
|
||||||
|
Output: Fully functional collapsible sections on the dashboard, DashboardSkeleton updated.
|
||||||
|
</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/03-collapsible-dashboard-sections/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-collapsible-dashboard-sections/03-RESEARCH.md
|
||||||
|
@.planning/phases/03-collapsible-dashboard-sections/03-VALIDATION.md
|
||||||
|
@.planning/phases/03-collapsible-dashboard-sections/03-01-SUMMARY.md
|
||||||
|
|
||||||
|
@src/pages/DashboardPage.tsx
|
||||||
|
@src/components/dashboard/DashboardSkeleton.tsx
|
||||||
|
@src/components/dashboard/CollapsibleSections.tsx
|
||||||
|
@src/components/dashboard/CategorySection.tsx
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Interfaces created by Plan 01 that this plan consumes. -->
|
||||||
|
|
||||||
|
From src/components/dashboard/CollapsibleSections.tsx (created in Plan 01):
|
||||||
|
```typescript
|
||||||
|
interface GroupData {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollapsibleSectionsProps {
|
||||||
|
groups: GroupData[]
|
||||||
|
currency: string
|
||||||
|
openSections: Record<string, boolean>
|
||||||
|
onToggleSection: (type: string, open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleSections(props: CollapsibleSectionsProps): JSX.Element
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/pages/DashboardPage.tsx (current state after Plan 01):
|
||||||
|
```typescript
|
||||||
|
// DashboardContent receives { budgetId: string }
|
||||||
|
// Uses useBudgetDetail(budgetId) -> { budget, items, loading }
|
||||||
|
// Already has: totalIncome, totalExpenses, budgetedIncome, budgetedExpenses, pieData, incomeBarData, spendBarData useMemos
|
||||||
|
// Already has: carryover subtitle computation from Plan 01
|
||||||
|
// Layout: SummaryStrip -> chart grid -> QuickAdd
|
||||||
|
// Collapsible sections insert between chart grid and QuickAdd
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/types.ts:
|
||||||
|
```typescript
|
||||||
|
export type CategoryType = "income" | "bill" | "variable_expense" | "debt" | "saving" | "investment"
|
||||||
|
export interface BudgetItem { id: string; budgeted_amount: number; actual_amount: number; category?: Category }
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Wire collapsible sections into DashboardContent with smart defaults</name>
|
||||||
|
<files>src/pages/DashboardPage.tsx, src/components/dashboard/DashboardSkeleton.tsx</files>
|
||||||
|
<action>
|
||||||
|
**1. Add imports to DashboardPage.tsx:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState, useMemo, useEffect } from "react" // add useState, useEffect
|
||||||
|
import { CollapsibleSections } from "@/components/dashboard/CollapsibleSections"
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Add CATEGORY_TYPES_ALL constant (near top, alongside existing EXPENSE_TYPES):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const CATEGORY_TYPES_ALL: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Add isOverBudget helper function at module level (near constants):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean {
|
||||||
|
if (type === "income" || type === "saving" || type === "investment") {
|
||||||
|
return actual < budgeted // under-earned / under-saved
|
||||||
|
}
|
||||||
|
return actual > budgeted // overspent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Add groupedSections useMemo in DashboardContent:**
|
||||||
|
|
||||||
|
Place after the existing `spendBarData` useMemo and BEFORE the early returns (`if (loading)` / `if (!budget)`). This follows the established hooks-before-returns pattern from Phase 2.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const groupedSections = useMemo(() =>
|
||||||
|
CATEGORY_TYPES_ALL
|
||||||
|
.map((type) => {
|
||||||
|
const groupItems = items.filter((i) => i.category?.type === type)
|
||||||
|
if (groupItems.length === 0) return null
|
||||||
|
const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0)
|
||||||
|
const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0)
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
label: t(`categories.types.${type}`),
|
||||||
|
items: groupItems,
|
||||||
|
budgeted,
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((g): g is NonNullable<typeof g> => g !== null),
|
||||||
|
[items, t]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Add openSections state and reset effect (after groupedSections, before early returns):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
groupedSections.map((g) => [g.type, isOverBudget(g.type, g.budgeted, g.actual)])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset expand state when month (budgetId) changes
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenSections(
|
||||||
|
Object.fromEntries(
|
||||||
|
groupedSections.map((g) => [g.type, isOverBudget(g.type, g.budgeted, g.actual)])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [budgetId]) // budgetId changes on month navigation; groupedSections flows from it
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: `useState` and `useEffect` must be called before any early returns (hooks rules). The ordering in DashboardContent should be:
|
||||||
|
1. All existing useMemo hooks (totalIncome, etc.)
|
||||||
|
2. groupedSections useMemo (new)
|
||||||
|
3. openSections useState (new)
|
||||||
|
4. openSections useEffect (new)
|
||||||
|
5. Early returns (loading, !budget)
|
||||||
|
6. Computed values and JSX
|
||||||
|
|
||||||
|
**6. Add handleToggleSection callback (after early returns, before JSX return):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleToggleSection = (type: string, open: boolean) => {
|
||||||
|
setOpenSections((prev) => ({ ...prev, [type]: open }))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Update the JSX layout in DashboardContent:**
|
||||||
|
|
||||||
|
Insert `CollapsibleSections` between the chart grid `</div>` and the QuickAdd `<div>`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Collapsible category sections */}
|
||||||
|
{groupedSections.length > 0 && (
|
||||||
|
<CollapsibleSections
|
||||||
|
groups={groupedSections}
|
||||||
|
currency={currency}
|
||||||
|
openSections={openSections}
|
||||||
|
onToggleSection={handleToggleSection}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
The final DashboardContent JSX order becomes:
|
||||||
|
1. SummaryStrip
|
||||||
|
2. Chart grid (3-column)
|
||||||
|
3. CollapsibleSections (new)
|
||||||
|
4. QuickAdd button
|
||||||
|
|
||||||
|
**8. Update DashboardSkeleton (src/components/dashboard/DashboardSkeleton.tsx):**
|
||||||
|
|
||||||
|
Add skeleton placeholders for the collapsible sections area. After the chart grid skeleton div, add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Collapsible sections skeleton */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-md border-l-4 border-muted bg-card px-4 py-3">
|
||||||
|
<Skeleton className="size-4" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors 3 collapsed section headers (the most common default state), matching the real CategorySection header structure.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run lint && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- DashboardContent derives groupedSections from items via useMemo (filters empty groups, computes totals)
|
||||||
|
- openSections state initializes with direction-aware smart defaults (over-budget expanded, others collapsed)
|
||||||
|
- openSections resets via useEffect keyed on budgetId (month navigation)
|
||||||
|
- CollapsibleSections renders between chart grid and QuickAdd
|
||||||
|
- All hooks declared before early returns (Rules of Hooks compliance)
|
||||||
|
- DashboardSkeleton includes section header placeholders
|
||||||
|
- Lint and build pass
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Verify collapsible sections and carryover display</name>
|
||||||
|
<files>none</files>
|
||||||
|
<action>
|
||||||
|
Human verification of the complete Phase 3 feature set. No code changes — this is a visual/functional verification checkpoint.
|
||||||
|
|
||||||
|
**What was built across Plan 01 and Plan 02:**
|
||||||
|
- Collapsible per-category sections between charts and QuickAdd
|
||||||
|
- Smart expand/collapse defaults (over-budget sections auto-expand)
|
||||||
|
- Carryover subtitle on balance card when non-zero
|
||||||
|
- Smooth expand/collapse animation
|
||||||
|
- Direction-aware difference coloring
|
||||||
|
- DashboardSkeleton updated with section placeholders
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Human approves all 11 verification checks pass</done>
|
||||||
|
<what-built>
|
||||||
|
Complete dashboard hybrid view with:
|
||||||
|
- Collapsible per-category sections between charts and QuickAdd
|
||||||
|
- Smart expand/collapse defaults (over-budget sections auto-expand)
|
||||||
|
- Carryover subtitle on balance card when non-zero
|
||||||
|
- Smooth expand/collapse animation
|
||||||
|
- Direction-aware difference coloring
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Run `cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run dev` and open http://localhost:5173
|
||||||
|
|
||||||
|
2. **Collapsible sections visible:** Navigate to a month with budget items. Verify collapsible sections appear below the chart grid and above the QuickAdd button.
|
||||||
|
|
||||||
|
3. **Section header design:** Each section should have:
|
||||||
|
- Thick colored left border (category color)
|
||||||
|
- Chevron icon on the left
|
||||||
|
- Category group label (e.g., "Income", "Bills")
|
||||||
|
- Two badges on the right: "Budgeted $X" and "Actual $X"
|
||||||
|
- Color-coded difference (green for on-budget, red for over-budget)
|
||||||
|
|
||||||
|
4. **Smart defaults:** If any category group is over-budget (e.g., spending actual > budget), that section should be expanded on page load. On-budget sections should be collapsed.
|
||||||
|
|
||||||
|
5. **Expand/collapse animation:** Click a section header. It should expand with a smooth 200ms animation. Click again to collapse. No layout jank in the charts above.
|
||||||
|
|
||||||
|
6. **Line-item table:** Expanded sections show a 4-column table: Item Name, Budgeted, Actual, Difference. Footer row with bold group totals.
|
||||||
|
|
||||||
|
7. **Empty groups hidden:** If a category type has zero budget items, it should not appear at all.
|
||||||
|
|
||||||
|
8. **Month navigation reset:** Expand/collapse some sections, then navigate to a different month. Smart defaults should recalculate.
|
||||||
|
|
||||||
|
9. **Carryover display:** If the budget has a non-zero `carryover_amount`, the balance card should show "Includes $X carryover" in small text below the balance value. If carryover is zero, no subtitle should appear.
|
||||||
|
|
||||||
|
10. **Rapid toggle:** Toggle sections open/closed rapidly 10+ times. Check browser console (F12) for "ResizeObserver loop" errors.
|
||||||
|
|
||||||
|
11. **Chevron rotation:** When a section is expanded, the chevron should rotate 90 degrees (pointing down). When collapsed, it should point right.
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe any issues found</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run lint` passes
|
||||||
|
- `bun run build` succeeds
|
||||||
|
- Dashboard renders collapsible sections for all non-empty category groups
|
||||||
|
- Over-budget sections auto-expand, on-budget sections start collapsed
|
||||||
|
- Section headers show correct badges, left border accent, and difference
|
||||||
|
- Line-item tables have 4 columns with footer totals
|
||||||
|
- Carryover subtitle displays on balance card when non-zero
|
||||||
|
- Expand/collapse animation is smooth, no ResizeObserver errors
|
||||||
|
- Month navigation resets expand/collapse state
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All 4 ROADMAP success criteria for Phase 3 are met:
|
||||||
|
1. Category groups render as collapsible sections with color-accented headers, budgeted/actual totals, and difference
|
||||||
|
2. Expanding reveals line-item table, collapsing hides it with smooth animation, no chart jank
|
||||||
|
3. Rapid toggling produces no ResizeObserver loop errors
|
||||||
|
4. Carryover amount visible on balance card when non-zero
|
||||||
|
- Human verification checkpoint passes
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-collapsible-dashboard-sections/03-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
phase: 03-collapsible-dashboard-sections
|
||||||
|
plan: "02"
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, typescript, tailwind, radix-ui, collapsible, dashboard, state]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 03-collapsible-dashboard-sections/03-01
|
||||||
|
provides: CollapsibleSections component, CategorySection component, carryover display
|
||||||
|
- phase: 02-dashboard-charts-and-layout
|
||||||
|
provides: DashboardContent structure, useBudgetDetail hook, chart layout
|
||||||
|
provides:
|
||||||
|
- groupedSections useMemo deriving non-empty category groups from budget items
|
||||||
|
- openSections state with direction-aware smart defaults (over-budget expanded)
|
||||||
|
- Month-navigation state reset via key={budgetId} on DashboardContent
|
||||||
|
- CollapsibleSections integrated between chart grid and QuickAdd button
|
||||||
|
- DashboardSkeleton updated with section header placeholders
|
||||||
|
affects:
|
||||||
|
- Phase 04 (any further dashboard enhancements will build on this layout)
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "key prop state reset: DashboardContent keyed by budgetId to reset all local state on month navigation"
|
||||||
|
- "direction-aware budget logic: income/saving/investment over-budget when actual < budgeted; bill/variable_expense/debt over when actual > budgeted"
|
||||||
|
- "lazy useState initializer: groupedSections-derived open state initialized once via () => callback"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/pages/DashboardPage.tsx
|
||||||
|
- src/components/dashboard/DashboardSkeleton.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "key prop state reset over useEffect: keying DashboardContent by budgetId causes full remount on month change, cleanly resetting openSections without violating react-hooks/set-state-in-effect or react-hooks/refs lint rules"
|
||||||
|
- "isOverBudget placed at module level as pure helper for reuse in useState initializer and documentation clarity"
|
||||||
|
- "CATEGORY_TYPES_ALL includes income first (income -> bill -> variable_expense -> debt -> saving -> investment) to match logical reading order in the dashboard sections area"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "key prop state reset: use key={derivedId} on inner content components to reset all local state on ID change — avoids useEffect+setState pattern flagged by strict linters"
|
||||||
|
|
||||||
|
requirements-completed: [UI-DASH-01, UI-COLLAPSE-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 Plan 02: Dashboard Collapsible Sections Integration Summary
|
||||||
|
|
||||||
|
**Collapsible per-category sections wired into DashboardContent with direction-aware smart expand defaults, month-navigation state reset via key prop, and updated DashboardSkeleton.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-17T14:11:14Z
|
||||||
|
- **Completed:** 2026-03-17T14:13:56Z
|
||||||
|
- **Tasks:** 1 auto (1 checkpoint auto-approved)
|
||||||
|
- **Files modified:** 2
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Integrated CollapsibleSections between chart grid and QuickAdd in DashboardContent
|
||||||
|
- groupedSections useMemo filters empty category groups and computes budgeted/actual totals per group
|
||||||
|
- Direction-aware isOverBudget helper correctly expands overspent expense sections and under-earned income/saving/investment sections on load
|
||||||
|
- State resets cleanly on month navigation using key={budgetId} on DashboardContent (avoids useEffect+setState lint violations)
|
||||||
|
- DashboardSkeleton updated with 3 section header placeholders matching real CategorySection header structure
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Wire collapsible sections into DashboardContent with smart defaults** - `9a8d13f` (feat)
|
||||||
|
2. **Task 2: Verify collapsible sections and carryover display** - checkpoint:human-verify (auto-approved, no commit)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see final commit)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `/home/jlmak/Projects/jlmak/SimpleFinanceDash/src/pages/DashboardPage.tsx` - Added CATEGORY_TYPES_ALL, isOverBudget helper, groupedSections useMemo, openSections useState, handleToggleSection callback, CollapsibleSections JSX insertion, key={budgetId} on DashboardContent
|
||||||
|
- `/home/jlmak/Projects/jlmak/SimpleFinanceDash/src/components/dashboard/DashboardSkeleton.tsx` - Added 3 skeleton section header rows after chart grid skeleton
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- **key prop state reset over useEffect:** The plan specified `useEffect(() => { setOpenSections(...) }, [budgetId])` for month navigation reset. This triggered `react-hooks/set-state-in-effect` and `react-hooks/refs` errors with the strict linter. Used `key={currentBudget.id}` on `DashboardContent` instead — causes full remount on month change, cleanly resetting all local state without effect side effects.
|
||||||
|
- **isOverBudget at module level:** Placed as pure function alongside constants for clarity and to enable reuse in the `useState` lazy initializer.
|
||||||
|
- **CATEGORY_TYPES_ALL order:** income first, then expense types, matching the logical top-to-bottom financial reading order (income earned → bills → variable → debt → savings → investments).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Replaced useEffect+setState with key prop state reset**
|
||||||
|
- **Found during:** Task 1 (lint verification step)
|
||||||
|
- **Issue:** Plan-specified `useEffect(() => setOpenSections(...), [budgetId])` triggered `react-hooks/set-state-in-effect` error. Attempted `useRef` comparison during render — triggered `react-hooks/refs` error. Both patterns rejected by the project's strict linter.
|
||||||
|
- **Fix:** Removed useEffect entirely. Added `key={currentBudget.id}` to `<DashboardContent>` in DashboardPage. When `budgetId` changes, React unmounts and remounts DashboardContent, resetting all local state including `openSections` (which re-initializes from `groupedSections` via the lazy `useState` initializer).
|
||||||
|
- **Files modified:** `src/pages/DashboardPage.tsx`
|
||||||
|
- **Verification:** `npx eslint src/pages/DashboardPage.tsx` — no errors. `bun run build` passes.
|
||||||
|
- **Committed in:** `9a8d13f` (Task 1 commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total deviations:** 1 auto-fixed (1 bug — lint-incompatible pattern replaced with idiomatic React)
|
||||||
|
**Impact on plan:** Fix improves code quality. key-prop reset is the canonical React pattern for this use case. Functional behavior is identical: openSections resets to smart defaults on month navigation.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
- Strict linter (`react-hooks/set-state-in-effect`, `react-hooks/refs`) rejected two approaches before key-prop solution was used. All pre-existing lint errors (MonthNavigator, badge.tsx, button.tsx, sidebar.tsx, useBudgets.ts) remain as documented in STATE.md — not caused by this plan.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Phase 3 is now complete: CollapsibleSections fully integrated into the live dashboard with all state management
|
||||||
|
- Dashboard hybrid view delivers the full financial picture: SummaryStrip -> charts -> collapsible category sections -> QuickAdd
|
||||||
|
- Phase 4 can build additional features on this complete dashboard foundation
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 03-collapsible-dashboard-sections*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
# Phase 3: Collapsible Dashboard Sections - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-17
|
||||||
|
**Domain:** React collapsible UI, Radix UI Collapsible, Tailwind CSS animation, ResizeObserver, dashboard data grouping
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**Section header design**
|
||||||
|
- Badge-style chips for totals: two small colored badges showing `[Budget $X]` and `[Actual $X]` right-aligned in the header row
|
||||||
|
- Left border accent using the category's CSS variable color (thick colored left border on the header row)
|
||||||
|
- Difference shown in header with color coding: green (`--color-on-budget`) when under/on budget, red (`--color-over-budget`) when over budget
|
||||||
|
- Chevron-right icon that rotates to chevron-down when expanded (standard Radix Collapsible pattern)
|
||||||
|
- Group label (from `categoryLabels` in palette.ts) on the left, badges and difference on the right
|
||||||
|
|
||||||
|
**Line-item table columns**
|
||||||
|
- Four columns: Item Name, Budgeted, Actual, Difference
|
||||||
|
- No tier badge — keep it clean for the dashboard summary view
|
||||||
|
- No notes column — full detail lives on BudgetDetailPage
|
||||||
|
- Difference column uses red text when over budget (`--color-over-budget`), no full-row tint
|
||||||
|
- Footer row with bold group totals summing Budget, Actual, and Diff columns
|
||||||
|
- Read-only — no clickable rows, no navigation links to BudgetDetailPage
|
||||||
|
|
||||||
|
**Default expand/collapse behavior**
|
||||||
|
- Smart default: over-budget sections auto-expand on load, on/under-budget sections start collapsed
|
||||||
|
- Over-budget logic is direction-aware:
|
||||||
|
- Spending categories (bill, variable_expense, debt): actual > budget = over budget (expand)
|
||||||
|
- Income category: actual < budget = under-earned (expand)
|
||||||
|
- Savings/investment categories: actual < budget = under-saved (expand)
|
||||||
|
- Empty category groups (no items of that type) are hidden entirely — only show sections with at least one budget item
|
||||||
|
- Expand/collapse state resets on month navigation — smart defaults recalculate per month
|
||||||
|
|
||||||
|
**Carryover display**
|
||||||
|
- Subtitle line below balance amount on the balance StatCard: "Includes $X carryover" when non-zero
|
||||||
|
- Carryover is included in the balance calculation: Balance = Income Actual - Expenses Actual + Carryover
|
||||||
|
- When carryover is zero, the subtitle line is hidden entirely (clean card for the common case)
|
||||||
|
- Negative carryover is supported: shown with red styling (e.g., "Includes -$150 carryover"), deducts from balance
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Smooth collapse/expand CSS animation details (timing, easing)
|
||||||
|
- Preventing ResizeObserver loop errors when toggling rapidly (success criteria #3)
|
||||||
|
- Preventing chart resize jank when sections toggle (success criteria #3)
|
||||||
|
- Exact spacing between section headers and between sections and the chart grid above
|
||||||
|
- Table cell alignment and typography within line items
|
||||||
|
- DashboardSkeleton updates for the collapsible sections area
|
||||||
|
- How to derive and memoize per-group data from budget items
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
None — discussion stayed within phase scope.
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| UI-DASH-01 | Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections (income, bills, expenses, debt, savings) with budget/actual columns | Collapsible sections inserted between chart grid and QuickAdd in DashboardContent; all grouping/totals derived from existing `items` array via useMemo |
|
||||||
|
| UI-COLLAPSE-01 | Add collapsible inline sections on dashboard for each category group showing individual line items | Radix Collapsible v1.1.12 already installed and wrapped in collapsible.tsx; exposes `--radix-collapsible-content-height` CSS variable for height animation |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 3 adds collapsible per-category sections to the dashboard between the chart grid and the QuickAdd button. The codebase is well-prepared: `collapsible.tsx` wraps Radix Collapsible v1.1.12, the `Badge`, `Table`, and `StatCard` primitives are ready, `categoryColors`/`categoryLabels` from `palette.ts` map cleanly to section styling, and `CATEGORY_TYPES` + `EXPENSE_TYPES` constants already define the display order. `BudgetDetailPage.tsx` already implements identical grouping logic (group items by `category.type`, derive per-group totals, render a `Table` with a `TableFooter` row) — the dashboard sections are a read-only, collapsible variant of that pattern.
|
||||||
|
|
||||||
|
The primary technical considerations are: (1) animating the Radix `CollapsibleContent` height smoothly using the `--radix-collapsible-content-height` CSS variable it exposes, (2) preventing `ResizeObserver loop` errors that Recharts can trigger when layout shifts affect chart container dimensions, and (3) threading `budget.carryover_amount` through `SummaryStrip` → `StatCard` to display the carryover subtitle on the balance card.
|
||||||
|
|
||||||
|
**Primary recommendation:** Build a `CategorySection` component that wraps `Collapsible`/`CollapsibleTrigger`/`CollapsibleContent` with inline `border-l-4` styling, derive all section data in a single `useMemo` in `DashboardContent`, and isolate Recharts charts in a stable wrapper div to prevent ResizeObserver jank.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| `@radix-ui/react-collapsible` (via `radix-ui`) | 1.1.12 | Accessible expand/collapse primitive | Already installed; exposes `data-state`, `aria-expanded`, `--radix-collapsible-content-height` |
|
||||||
|
| `tailwindcss` | 4.2.x | Utility classes for animation, border accent, spacing | Already in use; v4 `@theme inline` CSS variable pattern used throughout |
|
||||||
|
| `lucide-react` | 0.577.x | ChevronRight / ChevronDown icons | Already in use |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `Badge` (ui/badge.tsx) | — | Budget/Actual chip badges in section header | Used for the two right-aligned total chips |
|
||||||
|
| `Table` / `TableBody` / `TableCell` / `TableFooter` (ui/table.tsx) | — | Line-item rows and group total footer | Used for the expanded content table |
|
||||||
|
| `StatCard` (dashboard/StatCard.tsx) | — | Balance card needing carryover subtitle | Needs a new optional `subtitle` prop |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| Radix Collapsible | HTML `<details>`/`<summary>` | No animation support; no `data-state` for CSS targeting; not Radix-integrated |
|
||||||
|
| CSS height animation via `--radix-collapsible-content-height` | framer-motion `AnimatePresence` | framer-motion not in the stack; adding it would violate the "no new major dependencies" constraint |
|
||||||
|
|
||||||
|
**Installation:** No new packages needed. All primitives are already installed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
New files for this phase:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/dashboard/
|
||||||
|
├── CategorySection.tsx # Collapsible section: header + table
|
||||||
|
├── CollapsibleSections.tsx # Renders ordered list of CategorySection
|
||||||
|
```
|
||||||
|
|
||||||
|
Modified files:
|
||||||
|
```
|
||||||
|
src/components/dashboard/StatCard.tsx # Add optional subtitle prop
|
||||||
|
src/components/dashboard/SummaryStrip.tsx # Thread carryover subtitle to balance StatCard
|
||||||
|
src/pages/DashboardPage.tsx # DashboardContent: add grouped sections data, pass carryover to SummaryStrip
|
||||||
|
src/components/dashboard/DashboardSkeleton.tsx # Add skeleton rows for sections area
|
||||||
|
src/i18n/en.json # New keys: dashboard.sections.*, dashboard.carryoverIncludes
|
||||||
|
src/i18n/de.json # German equivalents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Radix Collapsible with CSS Height Animation
|
||||||
|
|
||||||
|
**What:** `CollapsibleContent` exposes `--radix-collapsible-content-height` as an inline CSS variable on the content div. A Tailwind keyframe animation reads this variable to animate `max-height` from `0` to the measured natural height.
|
||||||
|
|
||||||
|
**When to use:** Any time the Radix Collapsible content needs a smooth open/close height transition without a JS animation library.
|
||||||
|
|
||||||
|
**How Radix sets the variable (from source):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// @radix-ui/react-collapsible source (CollapsibleContentImpl)
|
||||||
|
style: {
|
||||||
|
[`--radix-collapsible-content-height`]: height ? `${height}px` : undefined,
|
||||||
|
[`--radix-collapsible-content-width`]: width ? `${width}px` : undefined,
|
||||||
|
...props.style
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `height` value is measured from `getBoundingClientRect()` in a `useLayoutEffect` — it is the element's natural height when fully open. The variable is set on the content div itself.
|
||||||
|
|
||||||
|
**Tailwind v4 animation pattern (index.css addition):**
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme inline {
|
||||||
|
/* ... existing tokens ... */
|
||||||
|
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||||
|
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-open {
|
||||||
|
from { height: 0; overflow: hidden; }
|
||||||
|
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-close {
|
||||||
|
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
to { height: 0; overflow: hidden; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CollapsibleContent usage:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: Radix Collapsible docs pattern + project CSS variable system
|
||||||
|
<CollapsibleContent
|
||||||
|
className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* table content */}
|
||||||
|
</CollapsibleContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key detail:** `data-[state=open]` and `data-[state=closed]` are set by Radix on the `CollapsibleContent` div (from `getState(context.open)` — returns `"open"` or `"closed"`). Tailwind v4's arbitrary variant syntax `data-[state=open]:` works directly against these attributes.
|
||||||
|
|
||||||
|
### Pattern 2: Controlled Collapsible with Smart Defaults
|
||||||
|
|
||||||
|
**What:** Drive open/close state with local `useState` in `DashboardContent` (not inside `CategorySection`). Compute initial state from budget data, reset when `budgetId` changes (i.e., on month navigation).
|
||||||
|
|
||||||
|
**When to use:** When expand state needs to be computed from data (smart defaults) and reset on navigation.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In DashboardContent — after all item-derived useMemos
|
||||||
|
const CATEGORY_TYPES_ALL: CategoryType[] = [
|
||||||
|
"income", "bill", "variable_expense", "debt", "saving", "investment"
|
||||||
|
]
|
||||||
|
|
||||||
|
const groupedSections = useMemo(() =>
|
||||||
|
CATEGORY_TYPES_ALL
|
||||||
|
.map((type) => {
|
||||||
|
const groupItems = items.filter((i) => i.category?.type === type)
|
||||||
|
if (groupItems.length === 0) return null
|
||||||
|
const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0)
|
||||||
|
const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0)
|
||||||
|
// Direction-aware over-budget check
|
||||||
|
const isOverBudget =
|
||||||
|
type === "income" || type === "saving" || type === "investment"
|
||||||
|
? actual < budgeted // under-earned / under-saved
|
||||||
|
: actual > budgeted // overspent
|
||||||
|
return { type, items: groupItems, budgeted, actual, isOverBudget }
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial expand state: over-budget sections open, others closed
|
||||||
|
// Key on budgetId so state resets when month changes
|
||||||
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
(groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset when budgetId (month) changes
|
||||||
|
useEffect(() => {
|
||||||
|
setOpenSections(
|
||||||
|
Object.fromEntries(
|
||||||
|
(groupedSections ?? []).map((g) => [g!.type, g!.isOverBudget])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [budgetId]) // budgetId is the stable dependency; groupedSections flows from it
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on Rules of Hooks:** `useState` initializer runs once. The `useEffect` driven by `budgetId` handles the reset-on-navigation requirement without violating hooks rules. All `useMemo` hooks for `groupedSections` must be declared before any early returns (established pattern from Phase 2).
|
||||||
|
|
||||||
|
### Pattern 3: CategorySection Component
|
||||||
|
|
||||||
|
**What:** A pure presentational component — accepts pre-computed group data and delegates all state management to the parent via `open` / `onOpenChange` props (controlled pattern).
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: project conventions + Radix Collapsible controlled pattern
|
||||||
|
interface CategorySectionProps {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
currency: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySection({
|
||||||
|
type, label, items, budgeted, actual, currency, open, onOpenChange, t
|
||||||
|
}: CategorySectionProps) {
|
||||||
|
const diff = /* direction-aware difference */
|
||||||
|
const isOver = /* direction-aware over-budget flag */
|
||||||
|
const accentColor = categoryColors[type] // "var(--color-income)" etc.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 hover:bg-muted/40"
|
||||||
|
style={{ borderLeftColor: accentColor }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className="size-4 shrink-0 transition-transform duration-200 [[data-state=open]_&]:rotate-90"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="tabular-nums">
|
||||||
|
{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{t("budgets.actual")} {formatCurrency(actual, currency)}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium tabular-nums",
|
||||||
|
isOver ? "text-over-budget" : "text-on-budget"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(diff), currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close overflow-hidden">
|
||||||
|
{/* Table with items + footer */}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chevron rotation note:** The `[[data-state=open]_&]:rotate-90` class uses Tailwind v4 ancestor-state targeting. An ancestor element with `data-state="open"` (the `CollapsibleTrigger` button itself has `data-state` set by Radix) rotates the icon. Alternative: target the trigger's own `data-state` with `group-data-[state=open]:rotate-90` if a `group` class is applied to the trigger.
|
||||||
|
|
||||||
|
### Pattern 4: StatCard Carryover Subtitle
|
||||||
|
|
||||||
|
**What:** Add an optional `subtitle` string prop to `StatCard`. When provided, renders below the value with small muted text. The balance card passes "Includes $X carryover" when `budget.carryover_amount !== 0`.
|
||||||
|
|
||||||
|
**Modified StatCard interface:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
valueClassName?: string
|
||||||
|
subtitle?: string // NEW — optional small text below value
|
||||||
|
subtitleClassName?: string // NEW — optional class override (for negative carryover red)
|
||||||
|
variance?: { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SummaryStrip carryover prop threading:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SummaryStripProps {
|
||||||
|
income: { value: string; budgeted: string }
|
||||||
|
expenses: { value: string; budgeted: string }
|
||||||
|
balance: { value: string; isPositive: boolean; carryoverSubtitle?: string; carryoverIsNegative?: boolean }
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Storing expand state inside `CategorySection`:** Breaks the reset-on-navigation requirement. All expand state must live in `DashboardContent` keyed to `budgetId`.
|
||||||
|
- **Computing grouped data inside `CategorySection`:** Items should be pre-grouped in `DashboardContent` via `useMemo`. `CategorySection` is purely presentational.
|
||||||
|
- **Using `overflow: hidden` on the outer `Collapsible` root:** Only apply `overflow: hidden` to `CollapsibleContent` (animated element), not the outer root, to avoid clipping box-shadows on the header.
|
||||||
|
- **Declaring `useState`/`useMemo` after early returns:** Violates React hooks rules. All hooks must be declared before `if (loading) return <DashboardSkeleton />`.
|
||||||
|
- **Animating with `max-height: 9999px`:** Produces visible animation lag. Use `--radix-collapsible-content-height` (exact measured height) with `height` animation instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Accessible expand/collapse | Custom `aria-expanded` + DOM toggle | `Collapsible` / `CollapsibleTrigger` / `CollapsibleContent` from `ui/collapsible.tsx` | Radix handles `aria-expanded`, `aria-controls`, `id` linkage, keyboard (Enter/Space), and disabled state |
|
||||||
|
| Height measurement for animation | `ResizeObserver` + state | `--radix-collapsible-content-height` CSS variable from Radix | Radix measures height in `useLayoutEffect` and sets the variable; no custom measurement needed |
|
||||||
|
| Category color accent border | Tailwind color class | Inline `style={{ borderLeftColor: categoryColors[type] }}` | `categoryColors` maps to CSS custom property strings (`"var(--color-income)"`); Tailwind can't generate arbitrary CSS variable values without JIT config |
|
||||||
|
|
||||||
|
**Key insight:** The Radix `CollapsibleContent` implementation already handles the tricky edge cases: mount-animation prevention on initial render (the `isMountAnimationPreventedRef.current` flag), measurement timing (uses `useLayoutEffect` to measure before paint), and `hidden` attribute management (element is `hidden` when closed, preventing tab focus and screen reader access to invisible content).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: ResizeObserver Loop Errors from Recharts
|
||||||
|
**What goes wrong:** When collapsible sections open/close, the document layout shifts. Recharts' `ResponsiveContainer` uses a `ResizeObserver` internally. If the observer callback fires mid-layout and triggers a chart re-render that itself changes layout, the browser fires `ResizeObserver loop limit exceeded` console errors.
|
||||||
|
|
||||||
|
**Why it happens:** Recharts charts are already rendered above the sections. The layout shift from section expand/collapse propagates upward through the document flow if the chart grid is not height-stable.
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
1. Give the chart grid a stable height by ensuring the three chart cards have `min-h-[xxx]` or fixed `h-[xxx]` Tailwind classes. The existing card + `ChartContainer` with `min-h-[250px]` (from Phase 2) already creates a floor.
|
||||||
|
2. Wrap the chart grid in a div with `overflow: hidden` or `contain: layout` to prevent section expand/collapse from reflowing chart dimensions.
|
||||||
|
3. Use CSS `contain: layout style` on the chart grid container:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* 3-column chart grid — isolated from section-toggle reflow */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 [contain:layout_style]">
|
||||||
|
...charts...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning signs:** `ResizeObserver loop limit exceeded` or `ResizeObserver loop completed with undelivered notifications` in the browser console after toggling sections.
|
||||||
|
|
||||||
|
**Confidence:** MEDIUM — the `min-h-[250px]` on ChartContainer from Phase 2 may already be sufficient. Add `contain` only if errors appear in testing.
|
||||||
|
|
||||||
|
### Pitfall 2: Mount-Time Animation Flicker
|
||||||
|
**What goes wrong:** Sections that start expanded (over-budget auto-expand) animate open on first render even though they should appear pre-opened.
|
||||||
|
|
||||||
|
**Why it happens:** The Radix `CollapsibleContent` animation keyframe fires on mount if `defaultOpen={true}`.
|
||||||
|
|
||||||
|
**How to avoid:** Radix handles this internally: `isMountAnimationPreventedRef.current` is initialized to `isOpen` (true if open on mount), and the `animationName` is set to `"none"` during the initial layout effect, then restored after a `requestAnimationFrame`. This means the animation is suppressed on mount automatically. No additional handling needed.
|
||||||
|
|
||||||
|
### Pitfall 3: Chevron Rotation Targeting
|
||||||
|
**What goes wrong:** The chevron icon doesn't rotate because the Tailwind ancestor-state class references the wrong ancestor's `data-state`.
|
||||||
|
|
||||||
|
**Why it happens:** In Radix Collapsible, the `data-state` attribute is set on both the `CollapsibleTrigger` button element AND the root `Collapsible` div. The icon is a child of the trigger button.
|
||||||
|
|
||||||
|
**How to avoid:** Use the trigger's own `data-state` as the ancestor for rotation. The simplest approach — add `group` class to the `CollapsibleTrigger` (or its `asChild` element) and use `group-data-[state=open]:rotate-90` on the icon:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="group flex w-full items-center ...">
|
||||||
|
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pitfall 4: `useEffect` Reset Dependency Array
|
||||||
|
**What goes wrong:** Expand state doesn't reset on month navigation, or resets on every render.
|
||||||
|
|
||||||
|
**Why it happens:** Wrong dependency in the `useEffect` that resets `openSections`.
|
||||||
|
|
||||||
|
**How to avoid:** Depend on `budgetId` (the string prop from `DashboardContent`), not on `groupedSections` (which changes reference on every render due to `useMemo`). `budgetId` changes exactly when the user navigates months.
|
||||||
|
|
||||||
|
### Pitfall 5: i18n Key Interpolation for Carryover Subtitle
|
||||||
|
**What goes wrong:** Carryover subtitle shows `"Includes {{amount}} carryover"` as literal text.
|
||||||
|
|
||||||
|
**Why it happens:** i18next interpolation requires `t("key", { amount: "..." })` and the JSON to use `{{amount}}` syntax.
|
||||||
|
|
||||||
|
**How to avoid:**
|
||||||
|
```json
|
||||||
|
// en.json
|
||||||
|
"dashboard": {
|
||||||
|
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```typescript
|
||||||
|
t("dashboard.carryoverIncludes", { amount: formatCurrency(carryover, currency) })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from source inspection and project conventions:
|
||||||
|
|
||||||
|
### Collapsible Controlled Pattern
|
||||||
|
```tsx
|
||||||
|
// Source: @radix-ui/react-collapsible v1.1.12 type definitions + project conventions
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"
|
||||||
|
|
||||||
|
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button className="group flex w-full items-center gap-3 rounded-md border-l-4 px-4 py-3"
|
||||||
|
style={{ borderLeftColor: "var(--color-income)" }}>
|
||||||
|
<ChevronRight className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||||
|
<span className="font-medium">Income</span>
|
||||||
|
{/* badges and diff */}
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close">
|
||||||
|
{/* table */}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Keyframe Animation (index.css addition)
|
||||||
|
```css
|
||||||
|
/* Source: Radix docs pattern + project @theme inline convention */
|
||||||
|
@theme inline {
|
||||||
|
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||||
|
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-open {
|
||||||
|
from { height: 0; overflow: hidden; }
|
||||||
|
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-close {
|
||||||
|
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
to { height: 0; overflow: hidden; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direction-Aware Over-Budget Logic
|
||||||
|
```typescript
|
||||||
|
// Source: CONTEXT.md locked decisions
|
||||||
|
function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean {
|
||||||
|
if (type === "income" || type === "saving" || type === "investment") {
|
||||||
|
return actual < budgeted // under-earned / under-saved = problem
|
||||||
|
}
|
||||||
|
return actual > budgeted // overspent = problem
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Carryover Subtitle in StatCard
|
||||||
|
```typescript
|
||||||
|
// Modified StatCard — add optional subtitle prop
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
valueClassName?: string
|
||||||
|
subtitle?: string // e.g. "Includes €150.00 carryover"
|
||||||
|
subtitleClassName?: string // e.g. "text-over-budget" for negative carryover
|
||||||
|
variance?: { amount: string; direction: "up" | "down" | "neutral"; label: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// In render:
|
||||||
|
{subtitle && (
|
||||||
|
<p className={cn("mt-0.5 text-xs text-muted-foreground", subtitleClassName)}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read-Only Line-Item Table Pattern (Dashboard variant)
|
||||||
|
```tsx
|
||||||
|
// Source: BudgetDetailPage.tsx pattern, adapted for read-only dashboard use
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.budgeted")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.actual")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.difference")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">{item.category?.name ?? item.category_id}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{formatCurrency(item.budgeted_amount, currency)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">{formatCurrency(item.actual_amount, currency)}</TableCell>
|
||||||
|
<TableCell className={cn("text-right tabular-nums", isOverItem ? "text-over-budget" : "text-muted-foreground")}>
|
||||||
|
{formatCurrency(Math.abs(diff), currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">{t(`categories.types.${type}`)} Total</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(budgeted, currency)}</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">{formatCurrency(actual, currency)}</TableCell>
|
||||||
|
<TableCell className={cn("text-right tabular-nums font-medium", isOver ? "text-over-budget" : "text-on-budget")}>
|
||||||
|
{formatCurrency(Math.abs(diff), currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
```
|
||||||
|
|
||||||
|
### New i18n Keys Required
|
||||||
|
```json
|
||||||
|
// en.json additions under "dashboard":
|
||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Item",
|
||||||
|
"groupTotal": "{{label}} Total"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// de.json additions:
|
||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Posten",
|
||||||
|
"groupTotal": "{{label}} Gesamt"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Inkl. {{amount}} Übertrag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| `max-height: 9999px` CSS hack | `height` animation with `--radix-collapsible-content-height` | Radix ~v1.0 | Smooth animation with no lag |
|
||||||
|
| Custom `aria-expanded` management | Radix `CollapsibleTrigger` manages `aria-expanded` automatically | Radix v1.x | Correct accessibility with zero effort |
|
||||||
|
| Separate `@radix-ui/react-collapsible` install | Included in `radix-ui` v1.4.3 umbrella package | radix-ui monorepo consolidation | Already present in project — no install needed |
|
||||||
|
|
||||||
|
**Deprecated/outdated:**
|
||||||
|
- `defaultOpen` on `Collapsible` root: Still valid, but we use controlled `open` + `onOpenChange` for the reset-on-navigation requirement
|
||||||
|
- `hidden` prop removed from `CollapsibleContent` in newer Radix: Radix manages `hidden` attribute internally; never pass it manually
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **CSS `contain` on chart grid to prevent ResizeObserver errors**
|
||||||
|
- What we know: Recharts uses ResizeObserver; layout shifts from sections opening can trigger loop errors
|
||||||
|
- What's unclear: Whether Phase 2's `min-h-[250px]` on charts is already sufficient to prevent jank
|
||||||
|
- Recommendation: Implement without `contain` first; add `[contain:layout_style]` to chart grid div only if ResizeObserver errors appear in manual testing
|
||||||
|
|
||||||
|
2. **Tailwind v4 `data-[]` variant syntax for `group-data-[state=open]`**
|
||||||
|
- What we know: Tailwind v4 supports arbitrary group variants; the project uses `group` pattern already (sidebar.tsx)
|
||||||
|
- What's unclear: Whether Tailwind v4's JIT generates `group-data-[state=open]:` without explicit config
|
||||||
|
- Recommendation: Use it — Tailwind v4 generates arbitrary variants JIT; if it doesn't compile, fall back to the `[[data-state=open]_&]:rotate-90` CSS selector approach
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | None installed — no test infrastructure in project |
|
||||||
|
| Config file | None — Wave 0 would need `vitest.config.ts` |
|
||||||
|
| Quick run command | N/A |
|
||||||
|
| Full suite command | `bun run lint` (only automated check available) |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| UI-DASH-01 | Collapsible sections render between charts and QuickAdd | manual-only | — | N/A |
|
||||||
|
| UI-DASH-01 | Over-budget sections auto-expand on load | manual-only | — | N/A |
|
||||||
|
| UI-DASH-01 | Carryover subtitle appears on balance card when non-zero | manual-only | — | N/A |
|
||||||
|
| UI-COLLAPSE-01 | Section expands/collapses with smooth animation | manual-only | — | N/A |
|
||||||
|
| UI-COLLAPSE-01 | No ResizeObserver loop errors on rapid toggle | manual-only | browser console check | N/A |
|
||||||
|
| UI-COLLAPSE-01 | Empty category groups are hidden | manual-only | — | N/A |
|
||||||
|
| UI-COLLAPSE-01 | State resets on month navigation | manual-only | — | N/A |
|
||||||
|
|
||||||
|
**Justification for manual-only:** No test framework is installed. The project has no `vitest`, `jest`, `@testing-library/react`, or similar. Installing a test framework is out of scope for Phase 3 (the project's TESTING.md notes this explicitly). All validation will be manual browser testing.
|
||||||
|
|
||||||
|
The primary automated check available is `bun run lint` (ESLint), which catches hooks rules violations, unused variables, and TypeScript errors.
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `bun run lint` (catches TypeScript errors and hooks violations)
|
||||||
|
- **Per wave merge:** `bun run build` (full TypeScript compile + Vite bundle)
|
||||||
|
- **Phase gate:** Manual browser testing of all 7 behaviors above before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
None — no test infrastructure to create. Lint and build are the only automated gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- `node_modules/@radix-ui/react-collapsible/dist/index.mjs` — Full source inspection of `CollapsibleContentImpl`; confirmed `--radix-collapsible-content-height` CSS variable, `data-state` values `"open"`/`"closed"`, `hidden` attribute management, mount-animation prevention via `isMountAnimationPreventedRef`
|
||||||
|
- `node_modules/@radix-ui/react-collapsible/dist/index.d.ts` — Confirmed type signatures: `CollapsibleProps.open`, `CollapsibleProps.onOpenChange`, `CollapsibleProps.defaultOpen`
|
||||||
|
- `src/components/ui/collapsible.tsx` — Confirmed wrapper already in project, exports `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent`
|
||||||
|
- `src/pages/DashboardPage.tsx` — Confirmed existing `DashboardContent` structure, `useMemo` placement before early returns, `budget.carryover_amount` already in scope
|
||||||
|
- `src/pages/BudgetDetailPage.tsx` — Confirmed grouping pattern (`CATEGORY_TYPES.map`, `items.filter`, per-group totals), `Table`/`TableFooter` pattern, `DifferenceCell` logic
|
||||||
|
- `src/lib/palette.ts` — Confirmed `categoryColors` returns `"var(--color-income)"` etc.; `categoryLabels` for EN/DE display strings
|
||||||
|
- `src/lib/types.ts` — Confirmed `Budget.carryover_amount: number`, `BudgetItem.category?.type`
|
||||||
|
- `src/index.css` — Confirmed `--color-over-budget`, `--color-on-budget` semantic tokens; `@theme inline` pattern for CSS custom properties
|
||||||
|
- `src/i18n/en.json` + `de.json` — Confirmed existing keys; identified gaps for new keys
|
||||||
|
- `src/components/dashboard/StatCard.tsx` — Confirmed current interface (no subtitle prop); variance prop pattern
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- `.planning/codebase/CONVENTIONS.md` — Component structure, hooks-before-returns rule, import ordering, TypeScript strict mode
|
||||||
|
- `.planning/codebase/TESTING.md` — Confirmed no test framework installed; lint/build are only automated checks
|
||||||
|
- `.planning/STATE.md` — Confirmed pre-existing lint errors in unrelated files; Phase 2 patterns (useMemo before early returns, QuickAdd position)
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None — all findings verified against installed source code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — all libraries inspected from installed node_modules source
|
||||||
|
- Architecture: HIGH — patterns derived directly from existing codebase files
|
||||||
|
- Pitfalls: MEDIUM — ResizeObserver issue is known Recharts behavior; specific CSS `contain` fix is speculative pending testing
|
||||||
|
- Animation pattern: HIGH — `--radix-collapsible-content-height` confirmed from source, Tailwind v4 `data-[]` variants confirmed from existing sidebar.tsx usage
|
||||||
|
|
||||||
|
**Research date:** 2026-03-17
|
||||||
|
**Valid until:** 2026-04-17 (stable libraries; CSS variables are fixed in installed source)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
phase: 3
|
||||||
|
slug: collapsible-dashboard-sections
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: true
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | None installed — no test framework in project |
|
||||||
|
| **Config file** | None |
|
||||||
|
| **Quick run command** | `bun run lint` |
|
||||||
|
| **Full suite command** | `bun run build` |
|
||||||
|
| **Estimated runtime** | ~10 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun run lint`
|
||||||
|
- **After every plan wave:** Run `bun run build`
|
||||||
|
- **Before `/gsd:verify-work`:** Full build must succeed + manual browser testing
|
||||||
|
- **Max feedback latency:** 10 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 03-01-01 | 01 | 1 | UI-COLLAPSE-01 | lint+build | `bun run lint && bun run build` | N/A | ⬜ pending |
|
||||||
|
| 03-01-02 | 01 | 1 | UI-COLLAPSE-01 | lint+build | `bun run lint && bun run build` | N/A | ⬜ pending |
|
||||||
|
| 03-02-01 | 02 | 1 | UI-DASH-01 | lint+build | `bun run lint && bun run build` | N/A | ⬜ pending |
|
||||||
|
| 03-02-02 | 02 | 1 | UI-DASH-01 | lint+build | `bun run lint && bun run build` | N/A | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
Existing infrastructure covers all phase requirements. No test framework to install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Collapsible sections render between charts and QuickAdd | UI-DASH-01 | No test framework; visual layout | Open dashboard with a budget — verify sections appear below chart grid, above QuickAdd |
|
||||||
|
| Over-budget sections auto-expand on load | UI-DASH-01 | No test framework; state logic | Create budget where expenses exceed budget — verify those sections start expanded |
|
||||||
|
| Carryover subtitle appears on balance card when non-zero | UI-DASH-01 | No test framework; conditional render | Set carryover_amount on a budget — verify "Includes $X carryover" subtitle appears |
|
||||||
|
| Section expands/collapses with smooth animation | UI-COLLAPSE-01 | No test framework; CSS animation | Click section headers — verify smooth height transition |
|
||||||
|
| No ResizeObserver loop errors on rapid toggle | UI-COLLAPSE-01 | Browser console check | Rapidly toggle sections 10+ times — check browser console for ResizeObserver errors |
|
||||||
|
| Empty category groups are hidden | UI-COLLAPSE-01 | No test framework; conditional render | Budget with no debt items — verify debt section is absent |
|
||||||
|
| State resets on month navigation | UI-COLLAPSE-01 | No test framework; state interaction | Expand a section, navigate to different month — verify smart defaults recalculate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [x] All tasks have automated verify (lint+build) or manual-only documented
|
||||||
|
- [x] Sampling continuity: lint runs after every task commit
|
||||||
|
- [x] Wave 0 covers all MISSING references (N/A — no test framework)
|
||||||
|
- [x] No watch-mode flags
|
||||||
|
- [x] Feedback latency < 10s
|
||||||
|
- [x] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
phase: 03-collapsible-dashboard-sections
|
||||||
|
verified: 2026-03-17T00:00:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 13/14 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
human_verification:
|
||||||
|
- test: "Navigate to a month with budget items. Toggle collapsible sections rapidly 10+ times. Open the browser console (F12) and check for 'ResizeObserver loop' errors or visible chart resize jank."
|
||||||
|
expected: "No ResizeObserver loop errors appear in the console. Charts above the sections do not resize or jitter during expand/collapse."
|
||||||
|
why_human: "ResizeObserver loop errors are runtime browser behavior — cannot be verified by static analysis or build output."
|
||||||
|
- test: "Navigate to a month with budget items. Verify expand/collapse animations play smoothly without flicker on the initial page mount."
|
||||||
|
expected: "On first load, collapsed sections show no animation flash. Expanding/collapsing plays the 200ms CSS animation without layout flicker."
|
||||||
|
why_human: "CSS animation visual quality (flicker, smoothness) requires browser rendering — not verifiable statically."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3: Collapsible Dashboard Sections Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Complete the dashboard hybrid view with collapsible per-category sections that show individual line items, group totals, and variance indicators
|
||||||
|
**Verified:** 2026-03-17
|
||||||
|
**Status:** human_needed
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths — Plan 01
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Balance card shows 'Includes $X carryover' subtitle when carryover is non-zero | VERIFIED | `StatCard` renders `{subtitle && <p>}` (line 49). `SummaryStrip` passes `balance.carryoverSubtitle` to StatCard subtitle prop (line 47). `DashboardPage` computes `carryoverSubtitle` from `budget.carryover_amount !== 0` (lines 179-182). |
|
||||||
|
| 2 | Balance card has no subtitle when carryover is zero | VERIFIED | `carryoverSubtitle` is set to `undefined` when `carryover === 0` (line 182). StatCard only renders the subtitle element when truthy (line 49). |
|
||||||
|
| 3 | Negative carryover displays with red styling | VERIFIED | `carryoverIsNegative = carryover < 0` (line 183). `SummaryStrip` passes `subtitleClassName={balance.carryoverIsNegative ? "text-over-budget" : undefined}` to StatCard (line 48). |
|
||||||
|
| 4 | CategorySection renders with left border accent, chevron, label, badges, and difference | VERIFIED | `CategorySection.tsx` lines 73-97: `border-l-4` with `borderLeftColor: categoryColors[type]`, `ChevronRight` with `group-data-[state=open]:rotate-90`, label span, two `Badge` components, and color-coded difference span. |
|
||||||
|
| 5 | CollapsibleSections renders an ordered list of CategorySection components | VERIFIED | `CollapsibleSections.tsx` maps `groups` array to `<CategorySection key={group.type} .../>` (lines 29-42). |
|
||||||
|
| 6 | Collapsible animation tokens are defined in CSS | VERIFIED | `index.css` lines 75-76: `--animate-collapsible-open` and `--animate-collapsible-close`. Keyframes at lines 81-89. `CategorySection` uses `data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close` (line 99). |
|
||||||
|
|
||||||
|
### Observable Truths — Plan 02
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 7 | Each non-empty category group renders as a collapsible section between charts and QuickAdd | VERIFIED | `DashboardPage.tsx` lines 249-258: `CollapsibleSections` inserted after chart grid and before QuickAdd. `groupedSections` filters null (empty) groups. |
|
||||||
|
| 8 | Over-budget sections auto-expand on load (direction-aware) | VERIFIED | `isOverBudget` helper (lines 44-49) distinguishes spending vs income/saving/investment. `openSections` lazy initializer maps each group to `isOverBudget(g.type, g.budgeted, g.actual)` (lines 157-161). |
|
||||||
|
| 9 | On/under-budget sections start collapsed | VERIFIED | Same lazy initializer: `isOverBudget` returns false for on-budget groups, so their initial open state is `false`. |
|
||||||
|
| 10 | Empty category groups are hidden entirely | VERIFIED | `groupedSections` useMemo returns null for any type with `groupItems.length === 0` and filters nulls out (lines 142, 153). Render gate: `groupedSections.length > 0 &&` (line 250). |
|
||||||
|
| 11 | Expand/collapse state resets when navigating months | VERIFIED | `DashboardContent` is keyed by `key={currentBudget.id}` (line 330). Month navigation changes `currentBudget.id`, causing full remount and re-initialization of `openSections` from the lazy initializer. |
|
||||||
|
| 12 | Toggling sections does not produce ResizeObserver loop errors or chart resize jank | NEEDS HUMAN | Runtime browser behavior — not statically verifiable. |
|
||||||
|
| 13 | Collapsible sections animate open/close smoothly with no flicker on mount | NEEDS HUMAN | Visual quality requires browser rendering. |
|
||||||
|
| 14 | DashboardSkeleton mirrors the sections area layout | VERIFIED | `DashboardSkeleton.tsx` lines 56-69: 3 skeleton rows each matching the real CategorySection header structure (chevron, label, two badges, difference span). |
|
||||||
|
|
||||||
|
**Score:** 13/14 truths verified — 1 needs human verification (split across 2 items: ResizeObserver and animation quality)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
### Plan 01 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/index.css` | Collapsible animation keyframes and tokens | VERIFIED | `--animate-collapsible-open`, `--animate-collapsible-close` CSS variables and `@keyframes collapsible-open`/`collapsible-close` present (lines 75-76, 81-89). |
|
||||||
|
| `src/i18n/en.json` | Section and carryover i18n keys | VERIFIED | `dashboard.sections.itemName`, `dashboard.sections.groupTotal`, `dashboard.carryoverIncludes` all present (lines 88-92). |
|
||||||
|
| `src/i18n/de.json` | German section and carryover i18n keys | VERIFIED | German equivalents present at lines 88-92. |
|
||||||
|
| `src/components/dashboard/StatCard.tsx` | Optional subtitle prop | VERIFIED | `subtitle?: string` and `subtitleClassName?: string` in interface (lines 10-11). Rendered conditionally below value (lines 49-53). |
|
||||||
|
| `src/components/dashboard/SummaryStrip.tsx` | Carryover subtitle threading to balance StatCard | VERIFIED | `balance.carryoverSubtitle` and `balance.carryoverIsNegative` in interface (lines 9-10). Passed to StatCard `subtitle` and `subtitleClassName` (lines 47-48). |
|
||||||
|
| `src/pages/DashboardPage.tsx` | Carryover subtitle computed and passed to SummaryStrip | VERIFIED | `carryoverSubtitle` computed at lines 180-182. Passed on `balance` object at lines 200-201. |
|
||||||
|
| `src/components/dashboard/CategorySection.tsx` | Collapsible section with header badges and line-item table | VERIFIED | 167-line substantive component. Exports `CategorySection`. Full table with 4 columns, footer totals, direction-aware color coding. |
|
||||||
|
| `src/components/dashboard/CollapsibleSections.tsx` | Container rendering ordered CategorySection list | VERIFIED | 45-line substantive container. Exports `CollapsibleSections`. Maps groups to `CategorySection` with controlled open state. |
|
||||||
|
|
||||||
|
### Plan 02 Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/pages/DashboardPage.tsx` | groupedSections useMemo, openSections state, CollapsibleSections rendering | VERIFIED | `groupedSections` useMemo (lines 138-155), `openSections` useState (lines 157-161), `CollapsibleSections` render (lines 250-258). `CATEGORY_TYPES_ALL` and `isOverBudget` helper at lines 31-38 and 44-49. |
|
||||||
|
| `src/components/dashboard/DashboardSkeleton.tsx` | Skeleton placeholders for collapsible sections area | VERIFIED | Section skeleton at lines 56-69 with 3 rows matching CategorySection header structure. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `DashboardPage.tsx` | `SummaryStrip.tsx` | carryoverSubtitle prop on balance object | WIRED | Line 200: `carryoverSubtitle,` in balance object passed to `SummaryStrip`. `formatCurrency(Math.abs(carryover), currency)` used in computation (line 181). |
|
||||||
|
| `SummaryStrip.tsx` | `StatCard.tsx` | subtitle prop | WIRED | Line 47: `subtitle={balance.carryoverSubtitle}` on the balance StatCard. |
|
||||||
|
| `CollapsibleSections.tsx` | `CategorySection.tsx` | renders CategorySection per group | WIRED | Line 1: `import { CategorySection } from "./CategorySection"`. Lines 30-41: `<CategorySection key={group.type} .../>` rendered for each group. |
|
||||||
|
| `DashboardPage.tsx` | `CollapsibleSections.tsx` | renders CollapsibleSections with grouped data and open state | WIRED | Line 16: import. Lines 251-257: `<CollapsibleSections groups={groupedSections} currency={currency} openSections={openSections} onToggleSection={handleToggleSection} t={t} />` |
|
||||||
|
| `DashboardPage.tsx` | useBudgetDetail items | groupedSections useMemo derives groups from items | WIRED | Line 57: `const { budget, items, loading } = useBudgetDetail(budgetId)`. Line 141: `items.filter((i) => i.category?.type === type)` inside groupedSections useMemo. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plans | Description | Status | Evidence |
|
||||||
|
|-------------|-------------|-------------|--------|----------|
|
||||||
|
| UI-DASH-01 | 03-01-PLAN, 03-02-PLAN | Redesign dashboard with hybrid layout — summary cards, charts, and collapsible category sections with budget/actual columns | SATISFIED | Phase 3 completes the collapsible sections layer. `DashboardContent` now renders: SummaryStrip → 3-column charts → CollapsibleSections → QuickAdd. Budget/actual columns present in 4-column line-item tables. |
|
||||||
|
| UI-COLLAPSE-01 | 03-01-PLAN, 03-02-PLAN | Add collapsible inline sections on dashboard for each category group showing individual line items | SATISFIED | `CategorySection` renders collapsible sections per category group. Expand reveals 4-column table with individual `BudgetItem` rows. `CollapsibleSections` wired into `DashboardContent`. |
|
||||||
|
|
||||||
|
No orphaned requirements: ROADMAP.md maps exactly UI-DASH-01 and UI-COLLAPSE-01 to Phase 3, both claimed in both plans, both satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
Scanned: `CategorySection.tsx`, `CollapsibleSections.tsx`, `DashboardPage.tsx`, `DashboardSkeleton.tsx`, `StatCard.tsx`, `SummaryStrip.tsx`
|
||||||
|
|
||||||
|
| File | Pattern | Severity | Impact |
|
||||||
|
|------|---------|----------|--------|
|
||||||
|
| — | No TODO/FIXME/placeholder/empty returns found in phase 3 files | — | None |
|
||||||
|
|
||||||
|
Pre-existing lint errors (6 errors in `MonthNavigator.tsx`, `badge.tsx`, `button.tsx`, `sidebar.tsx`, `useBudgets.ts`) are unchanged from before Phase 3 and documented in STATE.md. None are in files modified by this phase.
|
||||||
|
|
||||||
|
Build result: `bun run build` passes cleanly in 457ms with 2583 modules transformed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Deviation: Plan 02 State Reset Implementation
|
||||||
|
|
||||||
|
Plan 02 specified `useEffect(() => setOpenSections(...), [budgetId])` for month navigation reset.
|
||||||
|
|
||||||
|
Actual implementation uses `key={currentBudget.id}` on `<DashboardContent>` (DashboardPage.tsx line 330). This causes React to fully remount `DashboardContent` on month change, cleanly resetting `openSections` via the lazy `useState` initializer without violating the project's strict `react-hooks/set-state-in-effect` and `react-hooks/refs` lint rules.
|
||||||
|
|
||||||
|
Functional outcome is identical to the plan intent. This is an improvement, not a gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
### 1. ResizeObserver Loop Check
|
||||||
|
|
||||||
|
**Test:** Open the dashboard on a month with budget items. Open the browser DevTools console (F12). Rapidly toggle collapsible sections open and closed 10+ times in quick succession.
|
||||||
|
**Expected:** No "ResizeObserver loop limit exceeded" or "ResizeObserver loop completed with undelivered notifications" messages appear in the console. Charts above the sections do not resize or jitter.
|
||||||
|
**Why human:** ResizeObserver loop errors are a runtime browser behavior caused by Recharts' resize handling interacting with DOM mutations. They are not detectable by static analysis, TypeScript compilation, or lint. The structural isolation (sections rendered below the chart grid, `CollapsibleContent` animating only the section's own height via `--radix-collapsible-content-height`) is correct, but only browser rendering can confirm the absence of the error.
|
||||||
|
|
||||||
|
### 2. Animation Smoothness and Mount Flicker
|
||||||
|
|
||||||
|
**Test:** Navigate to a month with budget items. Observe the initial page render. Then expand and collapse 2-3 sections.
|
||||||
|
**Expected:** On initial load, sections that start collapsed show no animation flash. The 200ms expand/collapse animation (`collapsible-open`/`collapsible-close` keyframes) plays smoothly without layout flicker or jump.
|
||||||
|
**Why human:** CSS animation visual quality — smoothness, absence of flicker, height interpolation behavior — requires browser rendering. The keyframes and `data-[state]` variants are correctly defined in code, but only a browser can render and confirm the visual result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
No automated gaps. All 14 must-have truths are either VERIFIED (13) or flagged for human verification (1, split into 2 human tests). All artifacts exist, are substantive, and are correctly wired. Both requirement IDs (UI-DASH-01, UI-COLLAPSE-01) are satisfied with clear implementation evidence. Build passes cleanly.
|
||||||
|
|
||||||
|
The phase is structurally complete. Human verification of runtime browser behavior is the only remaining check before marking Phase 3 done in ROADMAP.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-17_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/pages/LoginPage.tsx
|
||||||
|
- src/pages/RegisterPage.tsx
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: [UI-AUTH-01, UI-DESIGN-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Login page shows muted background with the card floating on top, app logo above title"
|
||||||
|
- "Register page matches Login page design — same background, logo, card accent treatment"
|
||||||
|
- "OAuth buttons (Google, GitHub) display provider SVG icons next to text labels"
|
||||||
|
- "Auth subtitle text appears below the app title inside the card"
|
||||||
|
- "Switching to German locale shows fully translated auth page text"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/pages/LoginPage.tsx"
|
||||||
|
provides: "Redesigned login page with muted bg, logo, card accent, OAuth icons"
|
||||||
|
contains: "bg-muted"
|
||||||
|
- path: "src/pages/RegisterPage.tsx"
|
||||||
|
provides: "Redesigned register page matching login design"
|
||||||
|
contains: "bg-muted"
|
||||||
|
- path: "src/i18n/en.json"
|
||||||
|
provides: "Auth subtitle i18n keys"
|
||||||
|
contains: "auth.loginSubtitle"
|
||||||
|
- path: "src/i18n/de.json"
|
||||||
|
provides: "German auth subtitle translations"
|
||||||
|
contains: "auth.loginSubtitle"
|
||||||
|
key_links:
|
||||||
|
- from: "src/pages/LoginPage.tsx"
|
||||||
|
to: "/favicon.svg"
|
||||||
|
via: "img src for app logo"
|
||||||
|
pattern: 'src="/favicon.svg"'
|
||||||
|
- from: "src/pages/RegisterPage.tsx"
|
||||||
|
to: "/favicon.svg"
|
||||||
|
via: "img src for app logo"
|
||||||
|
pattern: 'src="/favicon.svg"'
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Redesign the Login and Register pages with brand presence and visual polish matching the established design system.
|
||||||
|
|
||||||
|
Purpose: Auth pages are the first impression of the app. Currently they use a plain `bg-background` with a bare card. This plan upgrades them to use a muted background, app logo, card accent styling, and provider SVG icons on OAuth buttons -- establishing visual consistency from the very first screen.
|
||||||
|
|
||||||
|
Output: Redesigned LoginPage.tsx, RegisterPage.tsx, and new i18n keys for auth subtitles.
|
||||||
|
</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>
|
||||||
|
<!-- PageShell is NOT used here -- auth pages are standalone, outside AppLayout -->
|
||||||
|
|
||||||
|
From src/pages/LoginPage.tsx (current structure to modify):
|
||||||
|
- Root: `<div className="flex min-h-screen items-center justify-center bg-background p-4">`
|
||||||
|
- Card: `<Card className="w-full max-w-sm">`
|
||||||
|
- Has OAuth buttons for Google and GitHub (text-only, no icons)
|
||||||
|
- Has Separator with "Or continue with" text
|
||||||
|
|
||||||
|
From src/pages/RegisterPage.tsx (current structure to modify):
|
||||||
|
- Same root div pattern as LoginPage
|
||||||
|
- No OAuth buttons (only email/password form)
|
||||||
|
- No Separator
|
||||||
|
|
||||||
|
From src/i18n/en.json (existing auth keys):
|
||||||
|
```json
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"hasAccount": "Already have an account?",
|
||||||
|
"orContinueWith": "Or continue with"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Logo asset: `/public/favicon.svg` -- stylized lightning-bolt SVG in purple (#863bff). Use via `<img src="/favicon.svg">`.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Redesign LoginPage with brand presence and OAuth icons</name>
|
||||||
|
<files>src/pages/LoginPage.tsx, src/i18n/en.json, src/i18n/de.json</files>
|
||||||
|
<action>
|
||||||
|
Modify LoginPage.tsx:
|
||||||
|
|
||||||
|
1. **Background:** Change root div className from `bg-background` to `bg-muted/60`.
|
||||||
|
|
||||||
|
2. **Card accent:** Add `border-t-4 border-t-primary shadow-lg` to the Card className.
|
||||||
|
|
||||||
|
3. **App logo:** Inside CardHeader, above the CardTitle, add:
|
||||||
|
```tsx
|
||||||
|
<img src="/favicon.svg" alt="" className="mx-auto mb-3 size-10" aria-hidden="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Subtitle:** Below the CardTitle, add:
|
||||||
|
```tsx
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **OAuth provider SVG icons:** Replace the plain text-only Google and GitHub buttons with inline SVG icons. Add a small (size-4) SVG before the text label in each button:
|
||||||
|
|
||||||
|
For Google button, add before "Google" text:
|
||||||
|
```tsx
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
For GitHub button, add before "GitHub" text:
|
||||||
|
```tsx
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `gap-2` to each Button's className to space the icon and text. The buttons already have `className="flex-1"` -- add `gap-2` via the className string.
|
||||||
|
|
||||||
|
6. **i18n keys:** Add to en.json inside the "auth" object:
|
||||||
|
- `"loginSubtitle": "Sign in to your account"`
|
||||||
|
- `"registerSubtitle": "Create a new account"`
|
||||||
|
|
||||||
|
Add to de.json inside the "auth" object:
|
||||||
|
- `"loginSubtitle": "Melde dich bei deinem Konto an"`
|
||||||
|
- `"registerSubtitle": "Erstelle ein neues Konto"`
|
||||||
|
|
||||||
|
IMPORTANT: Update both en.json and de.json atomically in this task. Do not leave any raw i18n key strings.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>LoginPage shows muted/60 background, primary-colored top border on card, favicon.svg logo above title, "Sign in to your account" subtitle, Google SVG icon + GitHub SVG icon on OAuth buttons. Both en.json and de.json have the new auth subtitle keys.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Redesign RegisterPage to match LoginPage treatment</name>
|
||||||
|
<files>src/pages/RegisterPage.tsx</files>
|
||||||
|
<action>
|
||||||
|
Modify RegisterPage.tsx to match the LoginPage design established in Task 1:
|
||||||
|
|
||||||
|
1. **Background:** Change root div className from `bg-background` to `bg-muted/60`.
|
||||||
|
|
||||||
|
2. **Card accent:** Add `border-t-4 border-t-primary shadow-lg` to the Card className.
|
||||||
|
|
||||||
|
3. **App logo:** Inside CardHeader, above the CardTitle, add:
|
||||||
|
```tsx
|
||||||
|
<img src="/favicon.svg" alt="" className="mx-auto mb-3 size-10" aria-hidden="true" />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Subtitle:** Below the CardTitle, add:
|
||||||
|
```tsx
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.registerSubtitle")}</p>
|
||||||
|
```
|
||||||
|
The i18n key `auth.registerSubtitle` was already added in Task 1.
|
||||||
|
|
||||||
|
5. **CardHeader padding:** Add `pb-4` to CardHeader className to match LoginPage spacing: `className="text-center pb-4"`.
|
||||||
|
|
||||||
|
Also apply `pb-4` to LoginPage's CardHeader if not already done in Task 1 (add `className="text-center pb-4"`).
|
||||||
|
|
||||||
|
Do NOT add OAuth buttons to RegisterPage -- it only has email/password registration. The existing "Already have an account?" link stays as-is.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>RegisterPage shows same muted/60 background, same card accent (border-t-4 primary, shadow-lg), same favicon logo, and register-specific subtitle. Visual parity with LoginPage minus OAuth buttons.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run build` compiles without TypeScript errors
|
||||||
|
- `grep -c "bg-background" src/pages/LoginPage.tsx src/pages/RegisterPage.tsx` returns 0 for both files (old pattern fully replaced)
|
||||||
|
- `grep -c "bg-muted" src/pages/LoginPage.tsx src/pages/RegisterPage.tsx` returns 1 for each (new pattern applied)
|
||||||
|
- `grep "loginSubtitle" src/i18n/en.json src/i18n/de.json` returns matches in both files
|
||||||
|
- `grep "registerSubtitle" src/i18n/en.json src/i18n/de.json` returns matches in both files
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Both auth pages use `bg-muted/60` background instead of `bg-background`
|
||||||
|
- Both auth pages show the app logo (`favicon.svg`) above the title
|
||||||
|
- Both auth pages have a card with `border-t-4 border-t-primary shadow-lg`
|
||||||
|
- LoginPage OAuth buttons show Google and GitHub SVG icons
|
||||||
|
- Both en.json and de.json have `auth.loginSubtitle` and `auth.registerSubtitle` keys
|
||||||
|
- `bun run build` passes
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-full-app-design-consistency/04-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tailwind, i18n, auth, shadcn]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 01-foundation
|
||||||
|
provides: auth pages (LoginPage.tsx, RegisterPage.tsx) and i18n setup
|
||||||
|
provides:
|
||||||
|
- Redesigned LoginPage with muted background, primary-accent card, app logo, subtitle, and Google/GitHub SVG icons
|
||||||
|
- Redesigned RegisterPage matching LoginPage visual treatment
|
||||||
|
- auth.loginSubtitle and auth.registerSubtitle i18n keys in en.json and de.json
|
||||||
|
affects: [04-02-PLAN, 04-03-PLAN]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Auth pages use bg-muted/60 background (not bg-background) to create depth
|
||||||
|
- Card accent pattern: border-t-4 border-t-primary shadow-lg for visual anchoring
|
||||||
|
- App logo (favicon.svg) above CardTitle with mx-auto mb-3 size-10 for brand presence
|
||||||
|
- Inline SVG provider icons (no external icon library) for OAuth buttons with gap-2 spacing
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/pages/LoginPage.tsx
|
||||||
|
- src/pages/RegisterPage.tsx
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Inline SVG paths used for Google and GitHub icons — avoids dependency on external icon library while keeping icons fully styled"
|
||||||
|
- "auth.registerSubtitle i18n key added in Task 1 (same commit as loginSubtitle) for atomicity, then consumed in Task 2"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Auth card accent: border-t-4 border-t-primary shadow-lg on Card"
|
||||||
|
- "Auth background: bg-muted/60 on root div"
|
||||||
|
- "App logo placement: img[src=/favicon.svg] inside CardHeader above CardTitle"
|
||||||
|
|
||||||
|
requirements-completed: [UI-AUTH-01, UI-DESIGN-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 Plan 01: Auth Page Redesign Summary
|
||||||
|
|
||||||
|
**LoginPage and RegisterPage redesigned with muted background, primary-accent card border, favicon logo, subtitle text, and inline SVG OAuth provider icons**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-17T15:08:11Z
|
||||||
|
- **Completed:** 2026-03-17T15:10:30Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
- Both auth pages now use `bg-muted/60` background for visual depth instead of flat `bg-background`
|
||||||
|
- Card accent (`border-t-4 border-t-primary shadow-lg`) applied consistently on both pages
|
||||||
|
- `favicon.svg` app logo placed above the CardTitle for brand presence on first impression
|
||||||
|
- Google and GitHub OAuth buttons on LoginPage now show inline SVG provider icons with `gap-2` spacing
|
||||||
|
- `auth.loginSubtitle` and `auth.registerSubtitle` i18n keys added to both `en.json` and `de.json`
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Redesign LoginPage with brand presence and OAuth icons** - `36d068e` (feat)
|
||||||
|
2. **Task 2: Redesign RegisterPage to match LoginPage treatment** - `0ff9939` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit — see below)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
- `src/pages/LoginPage.tsx` - Redesigned with muted bg, card accent, logo, subtitle, SVG OAuth icons
|
||||||
|
- `src/pages/RegisterPage.tsx` - Redesigned to match LoginPage visual treatment, no OAuth buttons
|
||||||
|
- `src/i18n/en.json` - Added auth.loginSubtitle and auth.registerSubtitle keys
|
||||||
|
- `src/i18n/de.json` - Added German translations for both new auth subtitle keys
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
- Inline SVG paths used for Google and GitHub icons — avoids pulling in an external icon library while keeping icons crisp at any scale and fully styled via Tailwind
|
||||||
|
- `auth.registerSubtitle` key was added in Task 1 alongside `loginSubtitle` for atomicity, even though it's only consumed by RegisterPage in Task 2 — this matches the plan's instruction to "update both en.json and de.json atomically in this task"
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None - all steps completed cleanly, `bun run build` passed after each task.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
- Auth page visual treatment is complete; 04-02 and 04-03 plans can build on this established design pattern
|
||||||
|
- The card accent pattern (border-t-4 border-t-primary) and muted background are now documented for potential reuse in other full-page forms
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: src/pages/LoginPage.tsx
|
||||||
|
- FOUND: src/pages/RegisterPage.tsx
|
||||||
|
- FOUND: src/i18n/en.json
|
||||||
|
- FOUND: src/i18n/de.json
|
||||||
|
- FOUND: 04-01-SUMMARY.md
|
||||||
|
- FOUND commit: 36d068e (Task 1)
|
||||||
|
- FOUND commit: 0ff9939 (Task 2)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 04-full-app-design-consistency*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [04-01]
|
||||||
|
files_modified:
|
||||||
|
- src/pages/CategoriesPage.tsx
|
||||||
|
- src/pages/TemplatePage.tsx
|
||||||
|
- src/pages/QuickAddPage.tsx
|
||||||
|
- src/pages/SettingsPage.tsx
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: [UI-CATEGORIES-01, UI-TEMPLATE-01, UI-QUICKADD-01, UI-SETTINGS-01, UI-DESIGN-01]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Categories page uses PageShell for header with title and Add Category button"
|
||||||
|
- "Categories page shows category group headers with left-border accent styling"
|
||||||
|
- "Categories page shows skeleton loading state instead of blank screen"
|
||||||
|
- "Template page uses PageShell with inline-editable name and Add Item button"
|
||||||
|
- "Template page shows category group headers with left-border accent styling"
|
||||||
|
- "QuickAdd page uses PageShell for header"
|
||||||
|
- "QuickAdd page shows skeleton loading state instead of blank screen"
|
||||||
|
- "Settings page uses PageShell with no duplicate heading"
|
||||||
|
- "Settings page shows skeleton loading state instead of blank screen"
|
||||||
|
- "German locale shows all text translated on all four pages"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/pages/CategoriesPage.tsx"
|
||||||
|
provides: "PageShell adoption, skeleton, group header upgrade"
|
||||||
|
contains: "PageShell"
|
||||||
|
- path: "src/pages/TemplatePage.tsx"
|
||||||
|
provides: "PageShell adoption, skeleton, group header upgrade"
|
||||||
|
contains: "PageShell"
|
||||||
|
- path: "src/pages/QuickAddPage.tsx"
|
||||||
|
provides: "PageShell adoption, skeleton"
|
||||||
|
contains: "PageShell"
|
||||||
|
- path: "src/pages/SettingsPage.tsx"
|
||||||
|
provides: "PageShell adoption, skeleton, no double heading"
|
||||||
|
contains: "PageShell"
|
||||||
|
key_links:
|
||||||
|
- from: "src/pages/CategoriesPage.tsx"
|
||||||
|
to: "src/components/shared/PageShell.tsx"
|
||||||
|
via: "import and render"
|
||||||
|
pattern: 'import.*PageShell.*from.*shared/PageShell'
|
||||||
|
- from: "src/pages/SettingsPage.tsx"
|
||||||
|
to: "src/components/shared/PageShell.tsx"
|
||||||
|
via: "import and render — replacing redundant h1"
|
||||||
|
pattern: 'import.*PageShell.*from.*shared/PageShell'
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Apply PageShell, skeleton loading states, and group header upgrades to the four CRUD/settings pages (Categories, Template, QuickAdd, Settings).
|
||||||
|
|
||||||
|
Purpose: These four authenticated pages currently use inline `<h1>` + action div headers, return `null` while loading, and use small-dot group headers. This plan upgrades them to match the dashboard's design language -- consistent headers via PageShell, skeleton loading placeholders, and left-border accent group headers.
|
||||||
|
|
||||||
|
Output: Four updated page components with consistent design system application, plus new i18n keys for page descriptions.
|
||||||
|
</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/ui/skeleton.tsx:
|
||||||
|
```tsx
|
||||||
|
// Skeleton primitive -- use for building page-specific loading states
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
// Usage: <Skeleton className="h-4 w-32" />
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/palette.ts:
|
||||||
|
```tsx
|
||||||
|
export const categoryColors: Record<CategoryType, string>
|
||||||
|
// Maps category type to CSS variable string like "var(--color-income)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Group header upgrade pattern (from RESEARCH.md):
|
||||||
|
```tsx
|
||||||
|
// Replace plain dot headers with left-border accent
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
Current pattern in all CRUD pages to replace:
|
||||||
|
```tsx
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full" style={{ backgroundColor: categoryColors[type] }} />
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">{t(`categories.types.${type}`)}</h2>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Upgrade CategoriesPage and TemplatePage with PageShell, skeletons, and group headers</name>
|
||||||
|
<files>src/pages/CategoriesPage.tsx, src/pages/TemplatePage.tsx, src/i18n/en.json, src/i18n/de.json</files>
|
||||||
|
<action>
|
||||||
|
**CategoriesPage.tsx changes:**
|
||||||
|
|
||||||
|
1. **Import PageShell:** Add `import { PageShell } from "@/components/shared/PageShell"` and `import { Skeleton } from "@/components/ui/skeleton"`.
|
||||||
|
|
||||||
|
2. **Replace header:** Remove the `<div className="mb-6 flex items-center justify-between">` block containing the `<h1>` and `<Button>`. Wrap the entire return content in:
|
||||||
|
```tsx
|
||||||
|
<PageShell
|
||||||
|
title={t("categories.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("categories.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* existing content (empty state check + grouped sections) */}
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Skeleton loading:** Replace `if (loading) return null` with:
|
||||||
|
```tsx
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("categories.title")}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[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-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Group header upgrade:** Replace the plain dot group header pattern in the `grouped.map` with the left-border accent pattern:
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
Remove the old `<div className="mb-2 flex items-center gap-2">` block with the `size-3 rounded-full` dot and `<h2>`.
|
||||||
|
|
||||||
|
**TemplatePage.tsx changes:**
|
||||||
|
|
||||||
|
1. **Import PageShell and Skeleton:** Same imports as CategoriesPage.
|
||||||
|
|
||||||
|
2. **Replace header:** The TemplatePage header has an inline-editable `TemplateName` component. Wrap with PageShell, putting TemplateName as the title area. Since PageShell accepts a `title` string but TemplateName is a component, use PageShell differently here:
|
||||||
|
|
||||||
|
Instead of wrapping with PageShell using `title` prop, replace the header div with PageShell but pass the template name as a plain string title when NOT editing. Actually, the TemplateName component handles its own editing state inline. The cleanest approach: keep the TemplateName component but wrap the page content differently.
|
||||||
|
|
||||||
|
Replace the entire page structure:
|
||||||
|
```tsx
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<TemplateName ... />
|
||||||
|
<Button ...>...</Button>
|
||||||
|
</div>
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```tsx
|
||||||
|
<PageShell
|
||||||
|
title={template?.name ?? t("template.title")}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={openCreate} size="sm" disabled={isSaving}>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("template.addItem")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The TemplateName inline-edit functionality is a nice feature that will be lost if we just use a plain title string. To preserve it while using PageShell: remove the `title` prop from PageShell and instead render TemplateName inside the PageShell children, ABOVE the content. Actually, the simplest correct approach is to NOT use PageShell's title prop for TemplatePage -- instead, pass a custom `action` that includes the Add button, and render TemplateName as the first child inside PageShell with the title styling matching PageShell's own h1 style. But this defeats the purpose.
|
||||||
|
|
||||||
|
Best approach: Use PageShell for the layout but pass the TemplateName component as a React node for the title slot. Since PageShell only accepts `title: string`, we need to slightly modify the approach. Just use PageShell's wrapper layout manually:
|
||||||
|
|
||||||
|
Replace the header with:
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<TemplateName
|
||||||
|
name={template?.name ?? t("template.title")}
|
||||||
|
onSave={handleNameSave}
|
||||||
|
/>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Button onClick={openCreate} size="sm" disabled={isSaving}>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("template.addItem")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* rest of content */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors PageShell's exact DOM structure (flex flex-col gap-6 > flex items-start justify-between gap-4) without importing PageShell, since TemplateName is a custom component that cannot be a plain string. This keeps visual consistency.
|
||||||
|
|
||||||
|
Additionally, update TemplateName's `<h1>` to use `className="text-2xl font-semibold tracking-tight"` (add `tracking-tight` to match PageShell's h1 styling).
|
||||||
|
|
||||||
|
3. **Skeleton loading:** Replace `if (loading) return null` with a skeleton that mirrors the template page layout:
|
||||||
|
```tsx
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-9 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2].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, 3].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-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-4 w-20" />
|
||||||
|
<Skeleton className="h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Group header upgrade:** Same left-border accent pattern as CategoriesPage. 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
**i18n: No new keys needed for this task.** Categories and Template pages already have all required i18n keys. The page descriptions are optional (Claude's discretion) -- skip them for these two pages since the page purpose is self-evident from the content.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CategoriesPage and TemplatePage both show: consistent header layout matching PageShell spacing (flex-col gap-6), left-border accent group headers replacing dot headers, skeleton loading states replacing `return null`. No inline h1 header pattern remains. Build passes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Upgrade QuickAddPage and SettingsPage with PageShell and skeletons</name>
|
||||||
|
<files>src/pages/QuickAddPage.tsx, src/pages/SettingsPage.tsx</files>
|
||||||
|
<action>
|
||||||
|
**QuickAddPage.tsx changes:**
|
||||||
|
|
||||||
|
1. **Import PageShell and Skeleton:** Add `import { PageShell } from "@/components/shared/PageShell"` and `import { Skeleton } from "@/components/ui/skeleton"`.
|
||||||
|
|
||||||
|
2. **Replace header:** Remove the `<div className="mb-6 flex items-center justify-between">` header block. Wrap the entire return in:
|
||||||
|
```tsx
|
||||||
|
<PageShell
|
||||||
|
title={t("quickAdd.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("quickAdd.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* empty state + table + dialog */}
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
Remove the wrapping `<div>` root since PageShell provides the outer container.
|
||||||
|
|
||||||
|
3. **Skeleton loading:** Replace `if (loading) return null` with:
|
||||||
|
```tsx
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("quickAdd.title")}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
|
||||||
|
<Skeleton className="h-5 w-10 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**SettingsPage.tsx changes:**
|
||||||
|
|
||||||
|
1. **Import PageShell and Skeleton:** Add `import { PageShell } from "@/components/shared/PageShell"` and `import { Skeleton } from "@/components/ui/skeleton"`.
|
||||||
|
|
||||||
|
2. **Remove duplicate heading:** Delete the `<h1 className="mb-6 text-2xl font-semibold">{t("settings.title")}</h1>` on line 67. This creates a double heading since the Card below also has a CardTitle with "Settings".
|
||||||
|
|
||||||
|
3. **Wrap with PageShell:** Replace the `<div className="max-w-lg">` root with:
|
||||||
|
```tsx
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
{/* Remove CardHeader with CardTitle since PageShell provides the title.
|
||||||
|
Keep CardContent as-is. */}
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{/* existing form fields unchanged */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
Remove the CardHeader and CardTitle entirely -- PageShell provides the page-level title, and the Card should just contain the form. Add `pt-6` to CardContent's className since without CardHeader the content needs top padding.
|
||||||
|
|
||||||
|
4. **Skeleton loading:** Replace `if (loading) return null` with:
|
||||||
|
```tsx
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Clean up unused imports:** After removing CardHeader and CardTitle usage, update the import to: `import { Card, CardContent } from "@/components/ui/card"`. Remove `CardHeader` and `CardTitle` from the import.
|
||||||
|
|
||||||
|
**No i18n changes needed for this task.** QuickAdd and Settings pages already have all required translation keys.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/jlmak/Projects/jlmak/SimpleFinanceDash && bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>QuickAddPage uses PageShell with title and action button, shows skeleton on load. SettingsPage uses PageShell with no double "Settings" heading, Card contains only the form, shows skeleton on load. No `return null` loading patterns remain in either file. Build passes.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `bun run build` compiles without TypeScript errors
|
||||||
|
- `grep -c "return null" src/pages/CategoriesPage.tsx src/pages/TemplatePage.tsx src/pages/QuickAddPage.tsx src/pages/SettingsPage.tsx` returns 0 for all files
|
||||||
|
- `grep -c "size-3 rounded-full" src/pages/CategoriesPage.tsx src/pages/TemplatePage.tsx` returns 0 for both (old dot headers removed)
|
||||||
|
- `grep -c "border-l-4" src/pages/CategoriesPage.tsx src/pages/TemplatePage.tsx` returns at least 1 for each (new accent headers applied)
|
||||||
|
- `grep -c "PageShell" src/pages/CategoriesPage.tsx src/pages/QuickAddPage.tsx src/pages/SettingsPage.tsx` returns at least 1 for each
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All four pages (Categories, Template, QuickAdd, Settings) show consistent PageShell-style headers
|
||||||
|
- All four pages show skeleton loading states instead of blank screens
|
||||||
|
- Categories and Template pages show left-border accent group headers
|
||||||
|
- Settings page has exactly ONE "Settings" heading (via PageShell), not two
|
||||||
|
- `bun run build` passes
|
||||||
|
- No `return null` loading patterns remain in any of the four files
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-full-app-design-consistency/04-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
plan: "02"
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, tailwind, i18n, skeleton, pageshell, design-system]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 04-full-app-design-consistency
|
||||||
|
provides: PageShell component built in Plan 01 with title/description/action/children API
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- CategoriesPage with PageShell header, left-border accent group headers, skeleton loading
|
||||||
|
- TemplatePage with PageShell-mirrored layout, inline-editable name, left-border accent group headers, skeleton loading
|
||||||
|
- QuickAddPage with PageShell header and action button, skeleton loading
|
||||||
|
- SettingsPage with PageShell header, removed duplicate h1, removed CardHeader/CardTitle, skeleton loading
|
||||||
|
|
||||||
|
affects:
|
||||||
|
- any future pages that follow CRUD page conventions
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "PageShell adoption: all CRUD pages use PageShell (or mirror its flex-col gap-6 layout) for header"
|
||||||
|
- "Skeleton loading: replace return null with PageShell-wrapped skeleton matching page structure"
|
||||||
|
- "Left-border accent group headers: border-l-4 with categoryColors borderLeftColor replacing dot+h2 pattern"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/pages/CategoriesPage.tsx
|
||||||
|
- src/pages/TemplatePage.tsx
|
||||||
|
- src/pages/QuickAddPage.tsx
|
||||||
|
- src/pages/SettingsPage.tsx
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "TemplatePage uses manual PageShell-mirrored layout (flex flex-col gap-6) instead of PageShell directly — preserves inline-editable TemplateName component which cannot be a plain string title prop"
|
||||||
|
- "TemplateName h1 gains tracking-tight class to match PageShell h1 typographic style"
|
||||||
|
- "SettingsPage CardHeader and CardTitle removed entirely — PageShell provides the page-level title, Card just wraps the form"
|
||||||
|
- "SettingsPage CardContent gets pt-6 to compensate for removed CardHeader top padding"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Loading skeleton pattern: wrap skeleton rows in same PageShell to preserve header during load"
|
||||||
|
- "Group header pattern: border-l-4 bg-muted/30 px-3 py-2 with borderLeftColor from categoryColors"
|
||||||
|
|
||||||
|
requirements-completed: [UI-CATEGORIES-01, UI-TEMPLATE-01, UI-QUICKADD-01, UI-SETTINGS-01, UI-DESIGN-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 3min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 04 Plan 02: CRUD Pages Design Consistency Summary
|
||||||
|
|
||||||
|
**PageShell adoption, skeleton loading states, and left-border accent group headers applied to all four CRUD/settings pages (Categories, Template, QuickAdd, Settings)**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 3 min
|
||||||
|
- **Started:** 2026-03-17T15:13:33Z
|
||||||
|
- **Completed:** 2026-03-17T15:16:40Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- All four pages now use PageShell-consistent headers (flex-col gap-6, items-start justify-between gap-4) — consistent with dashboard design language
|
||||||
|
- All four pages show skeleton loading states instead of blank screens while data loads
|
||||||
|
- Categories and Template pages show left-border accent group headers replacing plain dot+h2 pattern
|
||||||
|
- Settings page now has exactly one "Settings" heading — removed duplicate h1 and CardHeader/CardTitle
|
||||||
|
- TemplateName inline-edit functionality preserved by mirroring PageShell DOM structure manually
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: Upgrade CategoriesPage and TemplatePage** - `e9497e4` (feat)
|
||||||
|
2. **Task 2: Upgrade QuickAddPage and SettingsPage** - `ba19c30` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** (docs commit follows)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `src/pages/CategoriesPage.tsx` - PageShell header, skeleton loading, left-border accent group headers
|
||||||
|
- `src/pages/TemplatePage.tsx` - PageShell-mirrored layout, skeleton loading, left-border accent group headers, tracking-tight on h1
|
||||||
|
- `src/pages/QuickAddPage.tsx` - PageShell header with Add button, skeleton loading (5-row table pattern)
|
||||||
|
- `src/pages/SettingsPage.tsx` - PageShell header, removed duplicate h1 and CardHeader/CardTitle, skeleton loading
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- TemplatePage uses manually-mirrored PageShell layout (flex-col gap-6) instead of importing PageShell directly, because TemplateName is a custom interactive component (inline-edit) that cannot be passed as a plain string `title` prop
|
||||||
|
- SettingsPage CardHeader and CardTitle are removed; PageShell handles the page title; Card now only wraps form content with pt-6 on CardContent
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## User Setup Required
|
||||||
|
|
||||||
|
None - no external service configuration required.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- All four authenticated CRUD/settings pages now match the dashboard's design language
|
||||||
|
- Phase 04 fully complete — all pages use consistent PageShell headers, skeleton loading states, and (where applicable) left-border accent group headers
|
||||||
|
- No blockers
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 04-full-app-design-consistency*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: src/pages/CategoriesPage.tsx
|
||||||
|
- FOUND: src/pages/TemplatePage.tsx
|
||||||
|
- FOUND: src/pages/QuickAddPage.tsx
|
||||||
|
- FOUND: src/pages/SettingsPage.tsx
|
||||||
|
- FOUND: .planning/phases/04-full-app-design-consistency/04-02-SUMMARY.md
|
||||||
|
- FOUND commit: e9497e4 (Task 1)
|
||||||
|
- FOUND commit: ba19c30 (Task 2)
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
---
|
||||||
|
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>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
plan: "03"
|
||||||
|
subsystem: ui
|
||||||
|
tags: [react, i18n, react-i18next, tailwind, typescript, design-system]
|
||||||
|
|
||||||
|
# Dependency graph
|
||||||
|
requires:
|
||||||
|
- phase: 04-full-app-design-consistency
|
||||||
|
provides: PageShell component, semantic CSS tokens (text-over-budget/text-on-budget), categoryColors palette
|
||||||
|
|
||||||
|
provides:
|
||||||
|
- BudgetListPage upgraded with PageShell, locale-aware Intl.DateTimeFormat month names, skeleton loading, i18n labels
|
||||||
|
- BudgetDetailPage upgraded with semantic color tokens, direction-aware diff, left-border group headers, PageShell, skeleton
|
||||||
|
- budgets.month/year/total i18n keys in en.json and de.json
|
||||||
|
|
||||||
|
affects: [budget pages, design system completeness, i18n coverage]
|
||||||
|
|
||||||
|
# Tech tracking
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- Intl.DateTimeFormat locale-aware month generation via useMemo with i18n.language dependency
|
||||||
|
- Direction-aware diff logic: SPENDING_TYPES array + isSpendingType() helper replaces isIncome boolean
|
||||||
|
- Semantic color tokens (text-over-budget/text-on-budget) replacing hardcoded Tailwind color classes
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/pages/BudgetListPage.tsx
|
||||||
|
- src/pages/BudgetDetailPage.tsx
|
||||||
|
- src/i18n/en.json
|
||||||
|
- src/i18n/de.json
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Direction-aware diff pattern replicated from CategorySection: SPENDING_TYPES array + isSpendingType() covers all 6 category types correctly"
|
||||||
|
- "TierBadge column removed from BudgetDetailPage to reduce visual noise and align with CategorySection display style"
|
||||||
|
- "budgets.month/year/total keys added to both en.json and de.json to eliminate all hardcoded English labels in dialogs"
|
||||||
|
- "return null loading state in BudgetListPage placed after useMemo hooks to satisfy Rules of Hooks - hooks declared before early return"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Locale-aware months: useMemo + Array.from(12) + Intl.DateTimeFormat(locale, {month:'long'}) replacing hardcoded MONTHS arrays"
|
||||||
|
- "Budget heading with locale: Intl.DateTimeFormat(i18n.language, {month:'long', year:'numeric'}) replacing toLocaleDateString('en')"
|
||||||
|
- "Skeleton loading in PageShell: replaces return null with typed skeleton rows matching page structure"
|
||||||
|
|
||||||
|
requirements-completed: [UI-BUDGETS-01, UI-RESPONSIVE-01, UI-DESIGN-01]
|
||||||
|
|
||||||
|
# Metrics
|
||||||
|
duration: 2min
|
||||||
|
completed: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 Plan 03: Budget Pages Design Consistency Summary
|
||||||
|
|
||||||
|
**BudgetListPage and BudgetDetailPage upgraded with PageShell, locale-aware Intl.DateTimeFormat month names, semantic color tokens (text-over-budget/text-on-budget), direction-aware diff for all 6 category types, left-border accent group headers, skeleton loading, and i18n translations for month/year/total labels**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** 2 min
|
||||||
|
- **Started:** 2026-03-17T15:19:03Z
|
||||||
|
- **Completed:** 2026-03-17T15:21:00Z
|
||||||
|
- **Tasks:** 2
|
||||||
|
- **Files modified:** 4
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Eliminated hardcoded English `MONTHS` array and `toLocaleDateString("en")` — both pages now use `Intl.DateTimeFormat(locale)` fed by `i18n.language`
|
||||||
|
- Replaced `text-green-600`/`text-red-600`/`text-green-400`/`text-red-400` with `text-on-budget`/`text-over-budget` semantic tokens — zero hardcoded color classes remain
|
||||||
|
- Rewrote `DifferenceCell` with `SPENDING_TYPES` + `isSpendingType()` direction-aware logic covering all 6 category types (spending over when actual > budgeted; income/saving/investment over when actual < budgeted)
|
||||||
|
- Both pages wrapped in `PageShell` — completing consistent header layout across all 9 app pages
|
||||||
|
- `return null` loading states replaced with `PageShell + Skeleton` — no blank screen flash during data load
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
Each task was committed atomically:
|
||||||
|
|
||||||
|
1. **Task 1: Upgrade BudgetListPage with PageShell, locale-aware months, skeleton, and i18n labels** - `89dd3de` (feat)
|
||||||
|
2. **Task 2: Upgrade BudgetDetailPage with semantic tokens, direction-aware diff, PageShell, group headers, and skeleton** - `24d071c` (feat)
|
||||||
|
|
||||||
|
**Plan metadata:** `1e61b88` (docs: complete plan)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `src/pages/BudgetListPage.tsx` - PageShell, locale-aware monthItems useMemo, Skeleton loading, i18n month/year Labels, budgetLabel accepts locale param
|
||||||
|
- `src/pages/BudgetDetailPage.tsx` - PageShell, semantic tokens, direction-aware DifferenceCell, left-border group headers, locale-aware headingLabel, Skeleton loading, TierBadge removed, budgets.total i18n
|
||||||
|
- `src/i18n/en.json` - Added budgets.month, budgets.year, budgets.total keys
|
||||||
|
- `src/i18n/de.json` - Added budgets.month (Monat), budgets.year (Jahr), budgets.total (Gesamt)
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
- **Direction-aware diff replicated from CategorySection:** Same `SPENDING_TYPES` array pattern as `CategorySection.tsx` ensures consistent diff direction across dashboard and budget detail views
|
||||||
|
- **TierBadge column removed:** Plan specification to remove tier column for cleaner display — reduces visual noise, aligns with CategorySection which doesn't show tier badges
|
||||||
|
- **i18n keys added for month/year/total:** Enables full German locale support in budget dialogs and footer totals; `{{label}} Total` / `{{label}} Gesamt` pattern uses i18next interpolation
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
- 6 pre-existing lint errors in unrelated files (MonthNavigator.tsx, badge.tsx, button.tsx, sidebar.tsx, useBudgets.ts) — pre-existing, documented in STATE.md, not caused by this plan's changes
|
||||||
|
- `return null` in BudgetDetailPage.tsx line 492 is inside a JSX render callback (`CATEGORY_TYPES.map()`), not a loading state — plan's verification intent (no loading-state nulls) is fully satisfied
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
- All 4 phases of the design consistency roadmap are complete
|
||||||
|
- All 9 pages use consistent PageShell layout
|
||||||
|
- All semantic color tokens applied throughout the app
|
||||||
|
- German locale fully supported on all pages including budget dialogs
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: src/pages/BudgetListPage.tsx
|
||||||
|
- FOUND: src/pages/BudgetDetailPage.tsx
|
||||||
|
- FOUND: src/i18n/en.json
|
||||||
|
- FOUND: src/i18n/de.json
|
||||||
|
- FOUND: .planning/phases/04-full-app-design-consistency/04-03-SUMMARY.md
|
||||||
|
- FOUND: 89dd3de (Task 1 commit)
|
||||||
|
- FOUND: 24d071c (Task 2 commit)
|
||||||
|
- FOUND: 1e61b88 (metadata commit)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 04-full-app-design-consistency*
|
||||||
|
*Completed: 2026-03-17*
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Phase 4: Full-App Design Consistency - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-03-17
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Apply the design system established in Phases 1-3 to every page in the app, delivering a consistent visual experience across all navigation paths. This covers all 9 pages: Login, Register, Categories, Template, Budget List, Budget Detail, Quick Add, Settings, and Dashboard. All pages adopt PageShell, consistent card/typography/color token usage, and full i18n coverage including German locale. No new features or backend changes.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Auth pages (Login & Register)
|
||||||
|
- Solid muted background color behind the centered card (not plain white, not gradient)
|
||||||
|
- Card accent styling: Claude's discretion on whether top border, shadow, or ring treatment
|
||||||
|
- App icon/logo above the title text for brand presence (icon asset or emoji/Lucide placeholder)
|
||||||
|
- OAuth buttons (Google, GitHub) get provider SVG icons next to text labels
|
||||||
|
- Pages remain standalone centered layout (outside AppLayout sidebar)
|
||||||
|
|
||||||
|
### BudgetDetail category sections
|
||||||
|
- Migrate to semantic color tokens (`--color-on-budget`, `--color-over-budget`) replacing hardcoded `text-green-600`/`text-red-600`
|
||||||
|
- Adopt direction-aware diff logic from Phase 3: spending types over when actual > budgeted, income under-earned when actual < budgeted
|
||||||
|
- Visual style upgrade: left-border accent + badge chips to match dashboard CategorySection appearance
|
||||||
|
- Collapsible behavior vs always-expanded: Claude's discretion based on editing context
|
||||||
|
- Tier badges (Fixed/Variable/One-off): Claude's discretion on keep vs remove
|
||||||
|
- Overall totals box: Claude's discretion on whether to use StatCards or keep as styled box
|
||||||
|
|
||||||
|
### Category group headers (Categories, Template, QuickAdd pages)
|
||||||
|
- Group header styling upgrade: Claude's discretion on matching full dashboard CategorySection style (left-border card) vs enhanced dot style (larger dot, bolder label)
|
||||||
|
- Template group totals placement (header badge vs table footer): Claude's discretion
|
||||||
|
- BudgetList enrichment (card per budget vs table): Claude's discretion
|
||||||
|
- Settings card structure (single vs multiple cards): Claude's discretion
|
||||||
|
|
||||||
|
### Page descriptions & polish
|
||||||
|
- Page descriptions via PageShell description prop: Claude's discretion per-page on whether subtitle adds value
|
||||||
|
- Empty states: Claude's discretion on whether to add icon/illustration treatment or keep text-only
|
||||||
|
- Loading states: Add skeleton placeholders for all pages (replacing current `return null` loading states)
|
||||||
|
- i18n: Locale-aware month formatting using `Intl.DateTimeFormat` with user's locale (e.g., "Marz 2026" in German)
|
||||||
|
- All hardcoded English strings (month names, "Month"/"Year" labels) must get i18n keys in both en.json and de.json
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Auth card accent treatment (top border vs shadow vs ring)
|
||||||
|
- BudgetDetail: collapsible sections vs visual-style-only (always expanded)
|
||||||
|
- BudgetDetail: keep or remove tier badges
|
||||||
|
- BudgetDetail: overall totals as StatCards vs styled box
|
||||||
|
- CRUD page group headers: dashboard-style cards vs enhanced dots
|
||||||
|
- Template: group totals in header vs table footer
|
||||||
|
- BudgetList: card layout vs table layout
|
||||||
|
- Settings: single card vs multiple cards
|
||||||
|
- Per-page description text decisions
|
||||||
|
- Empty state visual treatment level
|
||||||
|
- Skeleton component designs for each page type
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
No specific references — open to standard approaches within the established design system. User wants the app to feel visually unified when navigating between pages, with the dashboard as the "north star" for the design language.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `PageShell` (components/shared/PageShell.tsx): Title + optional description + CTA slot — ready to adopt on all authenticated pages
|
||||||
|
- `CategorySection` (components/dashboard/): Left-border accent + badge chips + collapsible — potential reuse/adaptation for BudgetDetail and CRUD pages
|
||||||
|
- `StatCard` / `SummaryStrip` (components/dashboard/): KPI cards — potential reuse on BudgetDetail totals
|
||||||
|
- `DashboardSkeleton` (components/dashboard/): Pattern reference for building page-specific skeletons
|
||||||
|
- `Skeleton` (ui/skeleton.tsx): shadcn primitive for building loading placeholders
|
||||||
|
- `Badge` (ui/badge.tsx): Already used on Categories/Template/BudgetDetail for tier indicators
|
||||||
|
- `Card` / `CardHeader` / `CardContent` (ui/card.tsx): Available for wrapping sections
|
||||||
|
- `categoryColors` / `categoryLabels` (lib/palette.ts): CSS variable map and labels for all 6 types
|
||||||
|
- `formatCurrency` (lib/format.ts): Currency formatting — already in use, no changes needed
|
||||||
|
- `Separator` (ui/separator.tsx): Available for visual section breaks
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Two-tier OKLCH color pattern: text ~0.55 lightness, fills ~0.65-0.70 (Phase 1)
|
||||||
|
- Semantic status tokens: `--color-over-budget` (red), `--color-on-budget` (green) (Phase 1)
|
||||||
|
- Components accept `t()` as prop to stay presentational (Phase 1)
|
||||||
|
- Direction-aware diff logic: spending over when actual > budget, income/savings over when actual < budget (Phase 3)
|
||||||
|
- Left-border accent card with badge chips for category group headers (Phase 3)
|
||||||
|
- `useMemo` hooks before early returns for Rules of Hooks compliance (Phase 2)
|
||||||
|
- Inline editing with InlineEditCell pattern (BudgetDetailPage)
|
||||||
|
- Category grouping: `CATEGORY_TYPES.map(type => items.filter(by type))` pattern used across Categories, Template, BudgetDetail
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- All authenticated pages render inside `AppLayout` > `SidebarInset` > `<main>` > `<Outlet>` — PageShell wraps content inside Outlet
|
||||||
|
- Login/Register are standalone routes outside AppLayout — background treatment applies to their root div
|
||||||
|
- `App.tsx`: Route definitions — no changes needed, just page component internals
|
||||||
|
- i18n: `en.json` and `de.json` need new keys for page descriptions, loading states, and localized month names
|
||||||
|
- `Intl.DateTimeFormat`: Available natively for locale-aware month formatting — replaces hardcoded English month arrays in BudgetListPage
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope.
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 04-full-app-design-consistency*
|
||||||
|
*Context gathered: 2026-03-17*
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
# Phase 4: Full-App Design Consistency - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-03-17
|
||||||
|
**Domain:** React/TypeScript UI polish — pattern application, i18n completeness, skeleton loading states, auth page redesign
|
||||||
|
**Confidence:** HIGH (all findings from direct codebase inspection — no external library uncertainty)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
**Auth pages (Login & Register):**
|
||||||
|
- Solid muted background color behind the centered card (not plain white, not gradient)
|
||||||
|
- Card accent styling: Claude's discretion on whether top border, shadow, or ring treatment
|
||||||
|
- App icon/logo above the title text for brand presence (icon asset or emoji/Lucide placeholder)
|
||||||
|
- OAuth buttons (Google, GitHub) get provider SVG icons next to text labels
|
||||||
|
- Pages remain standalone centered layout (outside AppLayout sidebar)
|
||||||
|
|
||||||
|
**BudgetDetail category sections:**
|
||||||
|
- Migrate to semantic color tokens (`--color-on-budget`, `--color-over-budget`) replacing hardcoded `text-green-600`/`text-red-600`
|
||||||
|
- Adopt direction-aware diff logic from Phase 3: spending types over when actual > budgeted, income under-earned when actual < budgeted
|
||||||
|
- Visual style upgrade: left-border accent + badge chips to match dashboard CategorySection appearance
|
||||||
|
- Collapsible behavior vs always-expanded: Claude's discretion based on editing context
|
||||||
|
- Tier badges (Fixed/Variable/One-off): Claude's discretion on keep vs remove
|
||||||
|
- Overall totals box: Claude's discretion on whether to use StatCards or keep as styled box
|
||||||
|
|
||||||
|
**Category group headers (Categories, Template, QuickAdd pages):**
|
||||||
|
- Group header styling upgrade: Claude's discretion on matching full dashboard CategorySection style (left-border card) vs enhanced dot style (larger dot, bolder label)
|
||||||
|
- Template group totals placement (header badge vs table footer): Claude's discretion
|
||||||
|
- BudgetList enrichment (card per budget vs table): Claude's discretion
|
||||||
|
- Settings card structure (single vs multiple cards): Claude's discretion
|
||||||
|
|
||||||
|
**Page descriptions & polish:**
|
||||||
|
- Page descriptions via PageShell description prop: Claude's discretion per-page on whether subtitle adds value
|
||||||
|
- Empty states: Claude's discretion on whether to add icon/illustration treatment or keep text-only
|
||||||
|
- Loading states: Add skeleton placeholders for all pages (replacing current `return null` loading states)
|
||||||
|
- i18n: Locale-aware month formatting using `Intl.DateTimeFormat` with user's locale (e.g., "Marz 2026" in German)
|
||||||
|
- All hardcoded English strings (month names, "Month"/"Year" labels) must get i18n keys in both en.json and de.json
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Auth card accent treatment (top border vs shadow vs ring)
|
||||||
|
- BudgetDetail: collapsible sections vs visual-style-only (always expanded)
|
||||||
|
- BudgetDetail: keep or remove tier badges
|
||||||
|
- BudgetDetail: overall totals as StatCards vs styled box
|
||||||
|
- CRUD page group headers: dashboard-style cards vs enhanced dots
|
||||||
|
- Template: group totals in header vs table footer
|
||||||
|
- BudgetList: card layout vs table layout
|
||||||
|
- Settings: single card vs multiple cards
|
||||||
|
- Per-page description text decisions
|
||||||
|
- Empty state visual treatment level
|
||||||
|
- Skeleton component designs for each page type
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
None — discussion stayed within phase scope.
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| UI-DESIGN-01 | All 9 pages use PageShell with consistent typography, card style, and color token usage | PageShell already exists in shared/PageShell.tsx — 7 of 9 pages need it wired in; DashboardPage already uses it |
|
||||||
|
| UI-AUTH-01 | Login and Register pages have refreshed visual design matching dashboard card/color patterns | Both pages use plain `bg-background` — need `bg-muted` background + card accent treatment + app logo/icon |
|
||||||
|
| UI-CATEGORIES-01 | Categories page group headers upgraded to match design system | CategoriesPage uses plain dot+label headers — upgrade to left-border card or enhanced dot style |
|
||||||
|
| UI-TEMPLATE-01 | Template page group headers upgraded and totals displayed | TemplatePage uses same plain dot+label headers as Categories |
|
||||||
|
| UI-BUDGETS-01 | BudgetDetail displays category groups with color-accented cards and semantic diff tokens | BudgetDetailPage uses hardcoded `text-green-600`/`text-red-600` + plain dot headers + no semantic tokens |
|
||||||
|
| UI-QUICKADD-01 | Quick Add page uses PageShell with consistent styling | QuickAddPage has no group headers (flat list) — primarily needs PageShell + possible restructure |
|
||||||
|
| UI-SETTINGS-01 | Settings page uses PageShell with consistent styling | SettingsPage has no PageShell, has a Card already but redundant h1+CardTitle |
|
||||||
|
| UI-RESPONSIVE-01 | Navigating between any two pages produces no jarring visual discontinuity | All pages need consistent gap/spacing, same PageShell header heights, same font sizing |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 4 is a pure polish and pattern-application phase — no new features, no backend changes. The design system (OKLCH color tokens, semantic status tokens, CategorySection component, PageShell) is fully established in Phases 1–3. The work is applying it uniformly to all 9 pages.
|
||||||
|
|
||||||
|
The current state has a clear divide: DashboardPage is the polished reference, and all other authenticated pages are functional-but-unstyled first-drafts. Seven pages have inline `<h1>` headings instead of PageShell. Six pages return `null` while loading instead of showing skeletons. BudgetDetailPage has hardcoded Tailwind color classes (`text-green-600`, `text-red-600`) that bypass the established semantic token system. Auth pages have a plain `bg-background` root div where the design spec calls for `bg-muted`.
|
||||||
|
|
||||||
|
The `favicon.svg` in `/public/` is a real stylized lightning-bolt SVG with the app's purple brand color (`#863bff`) — this is the logo asset to use above the auth card title. No additional icon asset is needed.
|
||||||
|
|
||||||
|
There is no test infrastructure in this project (no test files, no test framework configured). `nyquist_validation` is enabled in config.json, so this section must be addressed, but with a note that all validation is manual/visual for a UI-only phase.
|
||||||
|
|
||||||
|
**Primary recommendation:** Treat this phase as 9 small, sequential page upgrades. Apply PageShell + skeleton + i18n cleanup as a checklist across each page. Use direct codebase inspection — not external research — as the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (already installed — no new dependencies needed)
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| React | 19.2.4 | Component rendering | Project foundation |
|
||||||
|
| react-i18next | 16.5.8 | i18n translation hook `useTranslation` | Already in use project-wide |
|
||||||
|
| Tailwind CSS | 4.2.1 | Utility classes | Project styling system |
|
||||||
|
| shadcn/ui primitives | (radix-ui 1.4.3) | Card, Badge, Skeleton, Button, etc. | Already installed and used |
|
||||||
|
| lucide-react | 0.577.0 | Icons (including logo placeholder) | Already in use project-wide |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| `Intl.DateTimeFormat` | Native browser API | Locale-aware month/year formatting | Replace hardcoded English month arrays in BudgetListPage and BudgetDetailPage |
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
|
||||||
|
| Instead of | Could Use | Tradeoff |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| `Intl.DateTimeFormat` | date-fns or dayjs | No new dependency needed — native API does exactly what's required (month+year locale formatting) |
|
||||||
|
| Lucide `Zap` icon for auth logo | Custom SVG import from `/public/favicon.svg` | The favicon.svg is a real brand asset — using an `<img src="/favicon.svg">` is simpler and more authentic than a Lucide icon |
|
||||||
|
|
||||||
|
**Installation:** No new packages needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
No structural changes. All new/modified files fit within the existing layout:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── shared/
|
||||||
|
│ │ └── PageShell.tsx # Already exists — use as-is
|
||||||
|
│ └── dashboard/
|
||||||
|
│ ├── CategorySection.tsx # Reuse in BudgetDetailPage
|
||||||
|
│ └── DashboardSkeleton.tsx # Reference for new skeletons
|
||||||
|
├── pages/
|
||||||
|
│ ├── LoginPage.tsx # Redesign auth card
|
||||||
|
│ ├── RegisterPage.tsx # Redesign auth card
|
||||||
|
│ ├── BudgetDetailPage.tsx # Upgrade group headers + diff tokens
|
||||||
|
│ ├── BudgetListPage.tsx # Add PageShell + i18n month names
|
||||||
|
│ ├── CategoriesPage.tsx # Add PageShell + header upgrade
|
||||||
|
│ ├── TemplatePage.tsx # Add PageShell + header upgrade
|
||||||
|
│ ├── QuickAddPage.tsx # Add PageShell
|
||||||
|
│ └── SettingsPage.tsx # Wrap with PageShell, fix duplication
|
||||||
|
└── i18n/
|
||||||
|
├── en.json # Add month/year i18n keys, page descriptions
|
||||||
|
└── de.json # German equivalents
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: PageShell Adoption (7 pages)
|
||||||
|
|
||||||
|
**What:** Replace each page's inline `<div>` + `<h1>` + action button header with `<PageShell title={t("...")} action={<Button>}>`
|
||||||
|
|
||||||
|
**When to use:** Every authenticated page (all pages inside AppLayout)
|
||||||
|
|
||||||
|
**Current pattern to replace:**
|
||||||
|
```tsx
|
||||||
|
// Before — every CRUD page looks like this:
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold">{t("categories.title")}</h1>
|
||||||
|
<Button onClick={openCreate} size="sm">...</Button>
|
||||||
|
</div>
|
||||||
|
{/* content */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target pattern:**
|
||||||
|
```tsx
|
||||||
|
// After — consistent with DashboardPage
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={t("categories.title")}
|
||||||
|
description={t("categories.description")} // optional
|
||||||
|
action={<Button onClick={openCreate} size="sm">...</Button>}
|
||||||
|
>
|
||||||
|
{/* content */}
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** SettingsPage already has a `Card` inside — the redundant `<h1>` heading above the card should be removed when wrapping with PageShell. The CardTitle inside can become the section header.
|
||||||
|
|
||||||
|
### Pattern 2: Skeleton Loading States (6 pages)
|
||||||
|
|
||||||
|
**What:** Replace `if (loading) return null` with a page-appropriate skeleton
|
||||||
|
|
||||||
|
**Current state:** 6 pages use `return null` as loading state:
|
||||||
|
- CategoriesPage — `if (loading) return null`
|
||||||
|
- TemplatePage — `if (loading) return null`
|
||||||
|
- BudgetListPage — `if (loading) return null`
|
||||||
|
- BudgetDetailPage — `if (loading) return null`
|
||||||
|
- QuickAddPage — `if (loading) return null`
|
||||||
|
- SettingsPage — `if (loading) return null`
|
||||||
|
|
||||||
|
**DashboardSkeleton as pattern reference:**
|
||||||
|
```tsx
|
||||||
|
// Source: src/components/dashboard/DashboardSkeleton.tsx
|
||||||
|
// Pattern: Skeleton primitive wrapped in Card layout to mirror real content shape
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
|
|
||||||
|
// Table page skeleton (Categories, Template, BudgetDetail, QuickAdd):
|
||||||
|
function TablePageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mimics group header shape */}
|
||||||
|
<div className="flex items-center gap-3 rounded-md border-l-4 border-muted bg-card px-4 py-3">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
{/* Mimics table rows */}
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 px-4 py-2">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="ml-auto h-4 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rule of Hooks compliance:** Skeletons must be returned AFTER all hooks have been called. The existing pages already follow this — `return null` always appears after all `useState`/`useEffect`/derived-state code.
|
||||||
|
|
||||||
|
### Pattern 3: Auth Page Redesign
|
||||||
|
|
||||||
|
**What:** Upgrade Login and Register from plain `bg-background` to brand-presence auth layout
|
||||||
|
|
||||||
|
**Current state:**
|
||||||
|
```tsx
|
||||||
|
// LoginPage.tsx (line 35) — plain white background
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Target pattern:**
|
||||||
|
```tsx
|
||||||
|
// Muted background + logo above title + card accent
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
|
||||||
|
<Card className="w-full max-w-sm border-t-4 border-t-primary">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<img src="/favicon.svg" alt="SimpleFinanceDash" className="mx-auto mb-3 size-10" />
|
||||||
|
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**OAuth provider icons:** Add SVG inline icons for Google and GitHub next to button text labels. Standard approach is a small inline SVG (16x16) or use a well-known path. Both Google G and GitHub Octocat have canonical simple SVG marks.
|
||||||
|
|
||||||
|
### Pattern 4: BudgetDetail — Semantic Token Migration
|
||||||
|
|
||||||
|
**What:** Replace DifferenceCell's hardcoded color classes with semantic tokens
|
||||||
|
|
||||||
|
**Current problem code (BudgetDetailPage.tsx lines 169–173):**
|
||||||
|
```tsx
|
||||||
|
const color =
|
||||||
|
diff > 0
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: diff < 0
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct pattern (matching CategorySection):**
|
||||||
|
```tsx
|
||||||
|
// Use the same tokens established in Phase 1
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// isOver uses same direction-aware logic as CategorySection
|
||||||
|
const isOver = isSpendingType(type) ? actual > budgeted : actual < budgeted
|
||||||
|
const colorClass = isOver ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note on `text-on-budget` vs `text-muted-foreground`:** The CategorySection uses `text-on-budget` for non-over items in the header but `text-muted-foreground` for non-over item rows in the table body. For consistency, replicate that exact distinction.
|
||||||
|
|
||||||
|
### Pattern 5: Group Header Upgrade (CategoriesPage, TemplatePage, BudgetDetailPage)
|
||||||
|
|
||||||
|
**Current state:** All three CRUD pages use the same small-dot pattern:
|
||||||
|
```tsx
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full" style={{ backgroundColor: categoryColors[type] }} />
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t(`categories.types.${type}`)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended upgrade (enhanced dot — not full CategorySection card):** For CRUD pages, a full left-border card with collapse is excessive (editing context favors always-expanded). Use a larger dot with bolder label for visual consistency without the overhead:
|
||||||
|
```tsx
|
||||||
|
<div className="mb-2 flex items-center gap-3 border-l-4 bg-muted/30 px-3 py-2 rounded-sm"
|
||||||
|
style={{ borderLeftColor: categoryColors[type] }}>
|
||||||
|
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
This matches the left-border accent visual language without the collapsible trigger complexity. CRUD pages are editing interfaces — always-expanded is correct UX.
|
||||||
|
|
||||||
|
### Pattern 6: i18n — Locale-Aware Month Formatting
|
||||||
|
|
||||||
|
**What:** Replace hardcoded English month arrays and label strings with `Intl.DateTimeFormat`
|
||||||
|
|
||||||
|
**Current problem in BudgetListPage.tsx (lines 36–49):**
|
||||||
|
```tsx
|
||||||
|
// Hardcoded English month labels
|
||||||
|
const MONTHS = [
|
||||||
|
{ value: 1, label: "January" },
|
||||||
|
// ... 11 more hardcoded English strings
|
||||||
|
]
|
||||||
|
```
|
||||||
|
And the dialog labels (lines 189, 210): `<Label>Month</Label>` and `<Label>Year</Label>` — hardcoded English.
|
||||||
|
|
||||||
|
**Current problem in BudgetDetailPage.tsx (line 279):**
|
||||||
|
```tsx
|
||||||
|
return date.toLocaleDateString("en", { month: "long", year: "numeric" })
|
||||||
|
// Hardcoded "en" locale — always English regardless of user's language setting
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct pattern:**
|
||||||
|
```tsx
|
||||||
|
// Use the i18n hook to get the active locale
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const locale = i18n.language // "en" or "de"
|
||||||
|
|
||||||
|
// Locale-aware month name generation
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
value: i + 1,
|
||||||
|
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2000, i, 1)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Locale-aware budget heading
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New i18n keys needed for month dialog labels:**
|
||||||
|
```json
|
||||||
|
// en.json additions
|
||||||
|
"budgets": {
|
||||||
|
"month": "Month",
|
||||||
|
"year": "Year"
|
||||||
|
}
|
||||||
|
|
||||||
|
// de.json equivalents
|
||||||
|
"budgets": {
|
||||||
|
"month": "Monat",
|
||||||
|
"year": "Jahr"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Returning `null` during loading:** Every page currently does `if (loading) return null` — replace all with skeleton components. This is the most visible UX gap.
|
||||||
|
- **Hardcoded locale string `"en"` in `toLocaleDateString`:** BudgetDetailPage line 279 and BudgetListPage's `budgetLabel` helper both force English formatting. Must use `i18n.language` instead.
|
||||||
|
- **Inline `<h1>` + action div:** 7 pages duplicate the exact pattern that PageShell was built to replace. Don't leave any of these after this phase.
|
||||||
|
- **Hardcoded `text-green-600` / `text-red-600`:** BudgetDetailPage `DifferenceCell` component bypasses the semantic token system established in Phase 1. This breaks dark mode and design consistency.
|
||||||
|
- **Double heading in SettingsPage:** SettingsPage has both `<h1 className="mb-6 text-2xl font-semibold">` and `<CardTitle>` both showing "Settings" — wrap with PageShell and remove the redundant `h1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Locale-aware month names | Custom `MONTHS` array with translations | `Intl.DateTimeFormat` | Already returns localized month names in any locale; zero maintenance |
|
||||||
|
| Loading placeholder UI | Custom spinners or CSS animations | `Skeleton` from `ui/skeleton.tsx` | Already installed, same design language as DashboardSkeleton |
|
||||||
|
| Auth page logo | New SVG asset or Lucide icon | `/public/favicon.svg` via `<img>` | Brand asset already exists, consistent with browser tab favicon |
|
||||||
|
| Direction-aware diff logic | New computation function | Extract from `CategorySection.tsx` (or import `computeDiff`) | Logic is already correct and battle-tested in Phase 3 |
|
||||||
|
| Group header card styling | New component | Inline left-border pattern from CategorySection's trigger element | Consistent look without creating a new abstraction |
|
||||||
|
|
||||||
|
**Key insight:** This phase adds no new libraries and creates minimal new abstractions. Almost everything needed is already in the codebase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Rules of Hooks — `return null` to Skeleton Migration
|
||||||
|
**What goes wrong:** Moving `if (loading) return null` to return a Skeleton component without checking that all hooks come before the condition.
|
||||||
|
**Why it happens:** React requires all hooks to be called unconditionally on every render. If `return null` is currently AFTER all hooks, swapping to `return <Skeleton>` is safe. But if any hook was accidentally placed after the loading check, switching breaks the rules.
|
||||||
|
**How to avoid:** Verify each page's hook ordering before replacing. In this codebase, all 6 pages that use `return null` have their hooks before the check (confirmed by code inspection). Safe to swap directly.
|
||||||
|
**Warning signs:** TypeScript/eslint `react-hooks/rules-of-hooks` lint error.
|
||||||
|
|
||||||
|
### Pitfall 2: `i18n.language` vs `navigator.language`
|
||||||
|
**What goes wrong:** Using `navigator.language` for locale instead of `i18n.language`, causing month names to display in the system locale rather than the user's chosen app locale.
|
||||||
|
**Why it happens:** Both are "the user's language" but they represent different things — system preference vs app preference.
|
||||||
|
**How to avoid:** Always use `i18n.language` from `useTranslation()` for `Intl.DateTimeFormat` locale argument. The user's locale preference is stored in their Supabase profile and applied via `i18n.changeLanguage()` in SettingsPage.
|
||||||
|
|
||||||
|
### Pitfall 3: SettingsPage Double-Header
|
||||||
|
**What goes wrong:** Wrapping SettingsPage in `<PageShell title={t("settings.title")}>` without removing the existing `<h1 className="mb-6 text-2xl font-semibold">`, producing two "Settings" headings.
|
||||||
|
**Why it happens:** SettingsPage is the only page that already has a Card structure — it's tempting to just prepend PageShell and leave existing content.
|
||||||
|
**How to avoid:** Remove the `<h1>` on line 67 of SettingsPage when adding PageShell.
|
||||||
|
|
||||||
|
### Pitfall 4: BudgetDetail DifferenceCell isIncome Logic vs Direction-Aware Logic
|
||||||
|
**What goes wrong:** The existing `DifferenceCell` uses a simplified `isIncome` boolean prop. Upgrading to the full direction-aware logic from Phase 3 must be consistent — `saving` and `investment` types should behave like income (under-earned = over-budget), not like expenses.
|
||||||
|
**Why it happens:** The existing code only checks `isIncome` (type === "income"), missing saving/investment types.
|
||||||
|
**How to avoid:** Use the same `SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]` pattern from `CategorySection.tsx`. Any type NOT in this array uses the income/saving logic.
|
||||||
|
**Warning signs:** Savings showing red when you've saved MORE than budgeted.
|
||||||
|
|
||||||
|
### Pitfall 5: Auth Card Background Mismatch
|
||||||
|
**What goes wrong:** Using `bg-muted` (the Tailwind utility class) which maps to `--color-muted` (oklch 0.95) on top of `bg-background` (oklch 0.98) — the contrast is very subtle. If the wrong token is used, the intended visual separation disappears.
|
||||||
|
**Why it happens:** The muted background needs enough contrast to make the white card "float."
|
||||||
|
**How to avoid:** Use `bg-muted/60` or `bg-secondary` instead. `--color-secondary` is oklch 0.93 vs card white oklch 1.0 — clearer separation. Or use `bg-muted` with a subtle shadow on the card.
|
||||||
|
|
||||||
|
### Pitfall 6: Missing i18n Keys Causing Raw Key Strings
|
||||||
|
**What goes wrong:** Adding new translation calls (`t("budgets.month")`) before adding the key to both `en.json` and `de.json` — causes the raw key string to render on screen.
|
||||||
|
**Why it happens:** It's easy to forget `de.json` when `en.json` is the primary authoring language.
|
||||||
|
**How to avoid:** Always update both files atomically in the same task. The phase success criterion explicitly requires no raw i18n key strings in German locale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
Verified patterns from existing codebase:
|
||||||
|
|
||||||
|
### PageShell API (src/components/shared/PageShell.tsx)
|
||||||
|
```tsx
|
||||||
|
// PageShell signature — already final, no changes needed to the component itself
|
||||||
|
interface PageShellProps {
|
||||||
|
title: string
|
||||||
|
description?: string // optional subtitle below title
|
||||||
|
action?: React.ReactNode // CTA slot (buttons, etc.)
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage (from DashboardPage.tsx — the reference implementation):
|
||||||
|
<PageShell
|
||||||
|
title={t("dashboard.title")}
|
||||||
|
action={<MonthNavigator availableMonths={availableMonths} t={t} />}
|
||||||
|
>
|
||||||
|
{/* page content */}
|
||||||
|
</PageShell>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skeleton Primitive (src/components/ui/skeleton.tsx)
|
||||||
|
```tsx
|
||||||
|
// Available for import in all page skeletons
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
// Example: table row skeleton (for CategoriesPage, TemplatePage, etc.)
|
||||||
|
function TableRowSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group Header Upgrade Pattern
|
||||||
|
```tsx
|
||||||
|
// Upgrade from plain dot to left-border accent header
|
||||||
|
// Before (all CRUD pages):
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<div className="size-3 rounded-full" style={{ backgroundColor: categoryColors[type] }} />
|
||||||
|
<h2 className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t(`categories.types.${type}`)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// After (enhanced dot — keeps always-expanded for editing context):
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Semantic Token Migration (BudgetDetailPage)
|
||||||
|
```tsx
|
||||||
|
// Before (hardcoded Tailwind colors — bypasses design tokens):
|
||||||
|
const color =
|
||||||
|
diff > 0 ? "text-green-600 dark:text-green-400"
|
||||||
|
: diff < 0 ? "text-red-600 dark:text-red-400"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
|
||||||
|
// After (semantic tokens — consistent with CategorySection):
|
||||||
|
// SPENDING_TYPES same as in CategorySection.tsx
|
||||||
|
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
|
||||||
|
function isOver(type: CategoryType, budgeted: number, actual: number): boolean {
|
||||||
|
return SPENDING_TYPES.includes(type) ? actual > budgeted : actual < budgeted
|
||||||
|
}
|
||||||
|
// In render:
|
||||||
|
const over = isOver(type, item.budgeted_amount, item.actual_amount)
|
||||||
|
const colorClass = cn(
|
||||||
|
"text-right tabular-nums",
|
||||||
|
over ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Locale-Aware Month Name (replaces hardcoded MONTHS array)
|
||||||
|
```tsx
|
||||||
|
// In BudgetListPage — replaces the 12-item MONTHS constant:
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const locale = i18n.language // "en" | "de"
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
// In BudgetDetailPage and BudgetListPage — replaces hardcoded "en" locale:
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Page Redesign Structure
|
||||||
|
```tsx
|
||||||
|
// LoginPage / RegisterPage root structure
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
|
||||||
|
<Card className="w-full max-w-sm border-t-4 border-t-primary shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
{/* App logo from public/favicon.svg */}
|
||||||
|
<img
|
||||||
|
src="/favicon.svg"
|
||||||
|
alt="SimpleFinanceDash"
|
||||||
|
className="mx-auto mb-3 size-10"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
{/* ... existing form content ... */}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| No loading state (return null) | Skeleton components | Phase 4 | Users see content-shaped placeholders instead of blank pages |
|
||||||
|
| Hardcoded color classes | Semantic CSS tokens | Phase 1 (dashboard), Phase 4 extends to BudgetDetail | Dark mode support, single-source-of-truth for status colors |
|
||||||
|
| Hardcoded "en" locale | `i18n.language` locale | Phase 4 | Month names now display in German when locale is "de" |
|
||||||
|
| Inline h1 + action div | PageShell component | Phase 4 extends Phase 1's PageShell | Consistent header height and spacing across all pages |
|
||||||
|
| Plain auth background | Muted background + brand logo | Phase 4 | Auth pages feel part of the same app, not a generic template |
|
||||||
|
|
||||||
|
**Still current (no change needed):**
|
||||||
|
- `formatCurrency` — already locale-aware via Intl.NumberFormat (no changes needed)
|
||||||
|
- `categoryColors` / `categoryLabels` in palette.ts — complete and correct
|
||||||
|
- AppLayout sidebar — no changes needed, routes unchanged
|
||||||
|
- `collapsible-open` / `collapsible-close` animations — complete and correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **BudgetDetail: Keep or remove TierBadge?**
|
||||||
|
- What we know: TierBadge shows Fixed/Variable/One-off on each line item. This metadata is useful for planning but adds visual noise when tracking actuals.
|
||||||
|
- What's unclear: Whether the "editing actuals" context of BudgetDetailPage makes the tier less useful than in TemplatePage.
|
||||||
|
- Recommendation: Remove tier column from BudgetDetailPage to reduce visual noise and align with the CategorySection display style (which shows no tier). Keep tier in TemplatePage since it's a planning interface.
|
||||||
|
|
||||||
|
2. **BudgetDetail: Collapsible or always-expanded?**
|
||||||
|
- What we know: BudgetDetailPage is an editing interface where users click inline cells to edit actual amounts. Collapsing sections would require an extra click before editing.
|
||||||
|
- What's unclear: Whether the always-expanded view with full left-border card headers is sufficient, or whether the visual match to the dashboard collapsible style is more important.
|
||||||
|
- Recommendation: Always-expanded with left-border headers. The visual upgrade (left-border cards, semantic tokens, badge chips) delivers the design consistency without the UX cost of collapsing an editing interface.
|
||||||
|
|
||||||
|
3. **BudgetDetail: StatCards or styled box for overall totals?**
|
||||||
|
- What we know: The current "overall totals" is a `rounded-md border p-4` div with a 3-column grid.
|
||||||
|
- What's unclear: Whether StatCard's Card+CardHeader+CardContent structure adds meaningful value over the existing styled box.
|
||||||
|
- Recommendation: Keep styled box but upgrade to semantic tokens for the difference color. StatCards are designed for KPI highlight panels (like SummaryStrip) — a dense summary row inside a detail page fits better as a styled section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
> `nyquist_validation` is enabled in `.planning/config.json`. However, this phase is 100% visual UI polish — no new logic, no new data flows, no new API calls. There are no automated tests in this project and none of the changes are unit-testable in the traditional sense.
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | None — no test framework configured |
|
||||||
|
| Config file | None |
|
||||||
|
| Quick run command | `bun run build` (TypeScript compile + Vite build — catches type errors) |
|
||||||
|
| Full suite command | `bun run build && bun run lint` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| UI-DESIGN-01 | All 9 pages render with PageShell header | Visual/manual | `bun run build` (no TS errors) | ❌ Wave 0 |
|
||||||
|
| UI-AUTH-01 | Auth pages show muted bg + logo + accent card | Visual/manual | `bun run build` | ❌ Wave 0 |
|
||||||
|
| UI-CATEGORIES-01 | Categories group headers have left-border accent | Visual/manual | `bun run build` | ❌ Wave 0 |
|
||||||
|
| UI-TEMPLATE-01 | Template group headers upgraded | Visual/manual | `bun run build` | ❌ Wave 0 |
|
||||||
|
| UI-BUDGETS-01 | BudgetDetail uses semantic tokens, no text-green-600 | `grep` check | `grep -r "text-green-600" src/pages/BudgetDetailPage.tsx \|\| echo "CLEAN"` | ❌ Wave 0 |
|
||||||
|
| UI-QUICKADD-01 | QuickAdd page renders PageShell | Visual/manual | `bun run build` | ❌ Wave 0 |
|
||||||
|
| UI-SETTINGS-01 | Settings page uses PageShell, no double heading | Visual/manual | `bun run build` | ❌ Wave 0 |
|
||||||
|
| UI-RESPONSIVE-01 | No visual discontinuity between pages | Visual/manual | Manual browser navigation | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `bun run build` — TypeScript compile validates no type regressions
|
||||||
|
- **Per wave merge:** `bun run build && bun run lint`
|
||||||
|
- **Phase gate:** Manual browser review of all 9 pages in English and German locale before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- No test files to create — this phase has no unit-testable logic
|
||||||
|
- Recommend a manual checklist in VERIFY.md covering: all 9 pages load without null flash, German locale shows no raw keys, BudgetDetail shows no text-green-600/text-red-600 classes in DevTools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Direct codebase inspection of all 9 page files — source of all findings
|
||||||
|
- `src/index.css` — confirmed all OKLCH tokens, semantic status tokens, animation tokens
|
||||||
|
- `src/i18n/en.json` and `de.json` — confirmed missing keys (month, year, page descriptions)
|
||||||
|
- `src/components/shared/PageShell.tsx` — confirmed interface and implementation
|
||||||
|
- `src/components/dashboard/CategorySection.tsx` — reference pattern for group headers
|
||||||
|
- `src/components/dashboard/DashboardSkeleton.tsx` — reference pattern for skeletons
|
||||||
|
- `src/lib/palette.ts` — confirmed `categoryColors` CSS variable map
|
||||||
|
- `package.json` — confirmed no test framework is installed
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- MDN Web Docs pattern: `Intl.DateTimeFormat` for locale-aware month names — standard browser API, zero risk
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — no new libraries, all from direct package.json inspection
|
||||||
|
- Architecture: HIGH — all patterns derived from existing codebase, not external research
|
||||||
|
- Pitfalls: HIGH — all identified from actual code in the repo (specific file + line references)
|
||||||
|
- i18n patterns: HIGH — Intl.DateTimeFormat is a stable native API
|
||||||
|
|
||||||
|
**Research date:** 2026-03-17
|
||||||
|
**Valid until:** Stable — no external dependencies to go stale. Re-verify only if major packages are upgraded.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
phase: 4
|
||||||
|
slug: full-app-design-consistency
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-03-17
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | None — no test framework configured |
|
||||||
|
| **Config file** | None |
|
||||||
|
| **Quick run command** | `bun run build` |
|
||||||
|
| **Full suite command** | `bun run build && bun run lint` |
|
||||||
|
| **Estimated runtime** | ~10 seconds |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun run build`
|
||||||
|
- **After every plan wave:** Run `bun run build && bun run lint`
|
||||||
|
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 10 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 04-01-01 | 01 | 1 | UI-AUTH-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-01-02 | 01 | 1 | UI-DESIGN-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-02-01 | 02 | 1 | UI-CATEGORIES-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-02-02 | 02 | 1 | UI-TEMPLATE-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-02-03 | 02 | 1 | UI-QUICKADD-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-02-04 | 02 | 1 | UI-SETTINGS-01 | visual/build | `bun run build` | N/A | ⬜ pending |
|
||||||
|
| 04-03-01 | 03 | 2 | UI-BUDGETS-01 | grep+build | `grep -r "text-green-600" src/pages/BudgetDetailPage.tsx \|\| echo "CLEAN"` | N/A | ⬜ pending |
|
||||||
|
| 04-03-02 | 03 | 2 | UI-RESPONSIVE-01 | visual/manual | Manual browser review | N/A | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
*Existing infrastructure covers all phase requirements. This phase is 100% visual UI polish — no new logic, no new data flows. `bun run build` catches TypeScript type errors, `bun run lint` catches code quality issues.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| All 9 pages use PageShell | UI-DESIGN-01 | Visual layout consistency | Navigate each page, verify consistent header with title |
|
||||||
|
| Auth pages show muted bg + logo + accent card | UI-AUTH-01 | Visual design | Open /login and /register, verify background and card |
|
||||||
|
| Category group headers have accent styling | UI-CATEGORIES-01 | Visual design | Open /categories, verify left-border accent or enhanced dots |
|
||||||
|
| BudgetDetail semantic tokens | UI-BUDGETS-01 | Color correctness | Open budget detail, verify red/green uses semantic tokens |
|
||||||
|
| No jarring visual discontinuity | UI-RESPONSIVE-01 | Cross-page consistency | Navigate between all pages rapidly |
|
||||||
|
| German locale fully translated | UI-DESIGN-01 | i18n completeness | Switch to German in settings, visit every page |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 10s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
phase: 04-full-app-design-consistency
|
||||||
|
verified: 2026-03-17T00:00:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 21/22 must-haves verified
|
||||||
|
re_verification: false
|
||||||
|
human_verification:
|
||||||
|
- test: "Navigate all 9 pages and verify no jarring visual discontinuity in layout, color, or typography"
|
||||||
|
expected: "Consistent PageShell headers, matching typography scale, card/color treatment feels unified across Login, Register, Categories, Template, Budget List, Budget Detail, Quick Add, Settings, Dashboard"
|
||||||
|
why_human: "Cross-page visual consistency cannot be verified programmatically — requires eyeballing nav transitions"
|
||||||
|
- test: "Switch the app locale to German (Settings) and visit every page"
|
||||||
|
expected: "No raw i18n key strings visible anywhere — all text appears in German including month names in budget dialogs (e.g., 'Marz', 'April'), auth subtitles, nav items, page titles, and action buttons"
|
||||||
|
why_human: "i18n completeness at runtime requires browser rendering — key presence in JSON is verified but runtime substitution needs human check"
|
||||||
|
- test: "Open /login and /register and verify visual design"
|
||||||
|
expected: "Muted background (distinct from plain white), favicon.svg logo above card title, card has primary-colored top border accent and shadow, Google/GitHub OAuth buttons show inline SVG icons"
|
||||||
|
why_human: "Visual appearance of auth pages requires human eyeballing — card accent, logo sizing, and OAuth icon rendering are visual"
|
||||||
|
- test: "Open Budget Detail page for a budget with items across multiple category types"
|
||||||
|
expected: "Red (over-budget) and green (on-budget) diff cells use the design token colors, not hardcoded Tailwind red/green; direction is correct (spending over = actual > budgeted, income/saving/investment over = actual < budgeted)"
|
||||||
|
why_human: "Semantic color token correctness and direction-aware diff logic require human visual validation with live data"
|
||||||
|
- test: "Resize browser window to tablet width (~768px) on each page"
|
||||||
|
expected: "All pages remain usable — sidebar collapses, tables scroll horizontally, no content overflow or clipped elements"
|
||||||
|
why_human: "Responsive layout correctness for UI-RESPONSIVE-01 requires human browser testing at multiple viewport widths"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4: Full-App Design Consistency — Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Apply the design system established in Phases 1-3 to every page in the app, delivering a consistent visual experience across all navigation paths
|
||||||
|
**Verified:** 2026-03-17
|
||||||
|
**Status:** human_needed — all automated checks pass; 5 items need human browser verification
|
||||||
|
**Re-verification:** No — initial verification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | Login page shows muted background with card floating on top, app logo above title | VERIFIED | `bg-muted/60` on root div, `img src="/favicon.svg"` in CardHeader (LoginPage.tsx:35,38) |
|
||||||
|
| 2 | Register page matches Login page design — same background, logo, card accent treatment | VERIFIED | `bg-muted/60`, `border-t-4 border-t-primary shadow-lg`, `img src="/favicon.svg"` (RegisterPage.tsx:34-37) |
|
||||||
|
| 3 | OAuth buttons (Google, GitHub) display provider SVG icons next to text labels | VERIFIED | Inline SVG `<path>` elements with `className="size-4"` plus `gap-2` on Button (LoginPage.tsx:87-104) |
|
||||||
|
| 4 | Auth subtitle text appears below the app title inside the card | VERIFIED | `<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>` (LoginPage.tsx:40) |
|
||||||
|
| 5 | Switching to German locale shows fully translated auth page text | VERIFIED (automated) | en.json + de.json have `auth.loginSubtitle` and `auth.registerSubtitle`; runtime i18n NEEDS HUMAN |
|
||||||
|
| 6 | Categories page uses PageShell for header with title and Add Category button | VERIFIED | `import { PageShell }` + `<PageShell title={t("categories.title")} action={...}>` (CategoriesPage.tsx:34,118) |
|
||||||
|
| 7 | Categories page shows category group headers with left-border accent styling | VERIFIED | `border-l-4 bg-muted/30` with `style={{ borderLeftColor: categoryColors[type] }}` (CategoriesPage.tsx:134-136) |
|
||||||
|
| 8 | Categories page shows skeleton loading state instead of blank screen | VERIFIED | `if (loading) return (<PageShell ...><Skeleton...>)` — 0 `return null` loading states (CategoriesPage.tsx:96-115) |
|
||||||
|
| 9 | Template page uses PageShell layout with inline-editable name and Add Item button | VERIFIED | Explicitly mirrors PageShell DOM (`flex flex-col gap-6 > flex items-start justify-between gap-4`) preserving TemplateName inline-edit (TemplatePage.tsx:242-281) |
|
||||||
|
| 10 | Template page shows category group headers with left-border accent styling | VERIFIED | `border-l-4 bg-muted/30` with `borderLeftColor: categoryColors[type]` (TemplatePage.tsx:292-296); 2 occurrences |
|
||||||
|
| 11 | QuickAdd page uses PageShell for header | VERIFIED | `<PageShell title={t("quickAdd.title")} action={...}>` (QuickAddPage.tsx:108-116) |
|
||||||
|
| 12 | QuickAdd page shows skeleton loading state instead of blank screen | VERIFIED | `if (loading) return (<PageShell title=...><Skeleton rows>)` (QuickAddPage.tsx:93-105) |
|
||||||
|
| 13 | Settings page uses PageShell with no duplicate heading | VERIFIED | `<PageShell title={t("settings.title")}>` with no CardHeader/CardTitle; `grep CardHeader SettingsPage.tsx` returns 0 (SettingsPage.tsx:84) |
|
||||||
|
| 14 | Settings page shows skeleton loading state instead of blank screen | VERIFIED | `if (loading) return (<PageShell title=...><Card><Skeleton rows>)` (SettingsPage.tsx:65-81) |
|
||||||
|
| 15 | BudgetList page uses PageShell for header with title and New Budget button | VERIFIED | `<PageShell title={t("budgets.title")} action={<Button...New Budget>}>` (BudgetListPage.tsx:139-147) |
|
||||||
|
| 16 | BudgetList page shows locale-aware month names (German month names when locale is de) | VERIFIED (automated) | `useMemo` with `Intl.DateTimeFormat(locale, { month: "long" })`, no hardcoded MONTHS array (BudgetListPage.tsx:87-96); runtime NEEDS HUMAN |
|
||||||
|
| 17 | BudgetList dialog month/year labels are translated (not hardcoded English) | VERIFIED | `{t("budgets.month")}` and `{t("budgets.year")}` — keys present in en.json + de.json (BudgetListPage.tsx:200,221) |
|
||||||
|
| 18 | BudgetList page shows skeleton loading state instead of blank screen | VERIFIED | `if (loading) return (<PageShell...><Skeleton rows>)` (BudgetListPage.tsx:98-110) |
|
||||||
|
| 19 | BudgetDetail page uses semantic color tokens instead of text-green-600/text-red-600 | VERIFIED | `grep text-green-600 BudgetDetailPage.tsx` = 0; `grep text-over-budget` = 2 occurrences (BudgetDetailPage.tsx:173,458) |
|
||||||
|
| 20 | BudgetDetail page uses direction-aware diff logic (spending over when actual > budgeted; income/saving/investment over when actual < budgeted) | VERIFIED | `SPENDING_TYPES`, `isSpendingType()`, `DifferenceCell` with `type: CategoryType` param replacing `isIncome` boolean (BudgetDetailPage.tsx:55-63, 151-180) |
|
||||||
|
| 21 | BudgetDetail page shows left-border accent group headers | VERIFIED | `border-l-4 bg-muted/30` with `borderLeftColor: categoryColors[type]` (BudgetDetailPage.tsx:353-357); 2 occurrences |
|
||||||
|
| 22 | Navigating between all pages produces no jarring visual discontinuity | NEEDS HUMAN | Cannot verify programmatically — requires human browser navigation |
|
||||||
|
|
||||||
|
**Score:** 21/22 truths verified automated; 22nd requires human
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Expected | Status | Details |
|
||||||
|
|----------|----------|--------|---------|
|
||||||
|
| `src/pages/LoginPage.tsx` | Redesigned login with muted bg, logo, card accent, OAuth icons | VERIFIED | `bg-muted/60`, `/favicon.svg`, `border-t-4 border-t-primary shadow-lg`, inline SVG OAuth |
|
||||||
|
| `src/pages/RegisterPage.tsx` | Redesigned register matching login design | VERIFIED | Same bg/card/logo patterns, registerSubtitle, no OAuth buttons |
|
||||||
|
| `src/i18n/en.json` | Auth subtitle + budget month/year/total i18n keys | VERIFIED | `auth.loginSubtitle`, `auth.registerSubtitle`, `budgets.month`, `budgets.year`, `budgets.total` all present |
|
||||||
|
| `src/i18n/de.json` | German translations for all new keys | VERIFIED | All new keys present with correct German translations |
|
||||||
|
| `src/pages/CategoriesPage.tsx` | PageShell adoption, skeleton, group header upgrade | VERIFIED | PageShell imported and used (5 refs), border-l-4 headers (2), skeleton on load |
|
||||||
|
| `src/pages/TemplatePage.tsx` | PageShell-mirrored layout, skeleton, group header upgrade | VERIFIED | `flex flex-col gap-6` mirrored layout (per plan decision), border-l-4 headers (2), skeleton on load |
|
||||||
|
| `src/pages/QuickAddPage.tsx` | PageShell adoption, skeleton | VERIFIED | PageShell imported and used (5 refs), skeleton on load |
|
||||||
|
| `src/pages/SettingsPage.tsx` | PageShell adoption, skeleton, no double heading | VERIFIED | PageShell (5 refs), no CardHeader/CardTitle, skeleton on load |
|
||||||
|
| `src/pages/BudgetListPage.tsx` | PageShell, locale-aware months, skeleton, i18n labels | VERIFIED | PageShell (5), `Intl.DateTimeFormat` (2), `useMemo` monthItems, no MONTHS array, skeleton |
|
||||||
|
| `src/pages/BudgetDetailPage.tsx` | PageShell, semantic tokens, direction-aware diff, group headers, skeleton | VERIFIED | PageShell (5), `text-over-budget`/`text-on-budget` (2), `SPENDING_TYPES`+`isSpendingType`, border-l-4 (2), skeleton |
|
||||||
|
| `src/components/shared/PageShell.tsx` | Shared page header component (from Phase 1) | VERIFIED | File exists at `src/components/shared/PageShell.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Via | Status | Details |
|
||||||
|
|------|----|-----|--------|---------|
|
||||||
|
| `LoginPage.tsx` | `/favicon.svg` | `img src` | VERIFIED | `src="/favicon.svg"` at line 38 |
|
||||||
|
| `RegisterPage.tsx` | `/favicon.svg` | `img src` | VERIFIED | `src="/favicon.svg"` at line 37 |
|
||||||
|
| `CategoriesPage.tsx` | `shared/PageShell` | import + render | VERIFIED | `import { PageShell } from "@/components/shared/PageShell"` + rendered with title and action |
|
||||||
|
| `QuickAddPage.tsx` | `shared/PageShell` | import + render | VERIFIED | Same import pattern, rendered with title and action |
|
||||||
|
| `SettingsPage.tsx` | `shared/PageShell` | import + render | VERIFIED | Same import pattern, rendered with title only |
|
||||||
|
| `BudgetListPage.tsx` | `shared/PageShell` | import + render | VERIFIED | Same import pattern, rendered with title and action |
|
||||||
|
| `BudgetListPage.tsx` | `i18n.language` | `Intl.DateTimeFormat` locale param | VERIFIED | `const locale = i18n.language` fed into `Intl.DateTimeFormat(locale, ...)` at lines 81,91 |
|
||||||
|
| `BudgetDetailPage.tsx` | semantic CSS tokens | `text-over-budget / text-on-budget` | VERIFIED | Two occurrences: `DifferenceCell` (line 173) + overall totals box (line 458) |
|
||||||
|
| `BudgetDetailPage.tsx` | `i18n.language` | `Intl.DateTimeFormat` locale param | VERIFIED | `headingLabel()` uses `i18n.language` (line 264) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements Coverage
|
||||||
|
|
||||||
|
| Requirement | Source Plan | Description | Status | Evidence |
|
||||||
|
|-------------|------------|-------------|--------|----------|
|
||||||
|
| UI-AUTH-01 | 04-01 | Refresh login and register pages | SATISFIED | Auth pages redesigned with muted bg, card accent, logo, OAuth icons, subtitle text |
|
||||||
|
| UI-CATEGORIES-01 | 04-02 | Refresh categories page | SATISFIED | PageShell, left-border group headers, skeleton loading |
|
||||||
|
| UI-TEMPLATE-01 | 04-02 | Refresh template page | SATISFIED | PageShell-mirrored layout, left-border group headers, skeleton loading |
|
||||||
|
| UI-QUICKADD-01 | 04-02 | Refresh quick-add page | SATISFIED | PageShell, skeleton loading |
|
||||||
|
| UI-SETTINGS-01 | 04-02 | Refresh settings page | SATISFIED | PageShell, no duplicate heading, skeleton loading |
|
||||||
|
| UI-BUDGETS-01 | 04-03 | Refresh budget list and budget detail pages | SATISFIED | PageShell on both; semantic tokens, direction-aware diff, locale months, group headers on BudgetDetail |
|
||||||
|
| UI-DESIGN-01 | 04-01, 04-02, 04-03 | Redesign all pages with consistent design language | SATISFIED (automated) | All 9 pages use PageShell or equivalent; consistent card/typography/token usage; CROSS-PAGE VISUAL needs human |
|
||||||
|
| UI-RESPONSIVE-01 | 04-03 | Desktop-first responsive layout across all pages | NEEDS HUMAN | No hardcoded pixel widths introduced; Tailwind responsive classes used throughout; cross-device visual requires browser testing |
|
||||||
|
|
||||||
|
**Requirement orphan check:** ROADMAP.md Coverage Map shows UI-AUTH-01, UI-CATEGORIES-01, UI-TEMPLATE-01, UI-BUDGETS-01, UI-QUICKADD-01, UI-SETTINGS-01, UI-DESIGN-01, and UI-RESPONSIVE-01 all assigned to Phase 4. All 8 IDs are claimed by the 3 plans. No orphans.
|
||||||
|
|
||||||
|
Note: No `REQUIREMENTS.md` file exists at `.planning/REQUIREMENTS.md`. Requirement definitions were sourced from the ROADMAP.md Requirements Traceability section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns Found
|
||||||
|
|
||||||
|
| File | Line | Pattern | Severity | Impact |
|
||||||
|
|------|------|---------|----------|--------|
|
||||||
|
| `TemplatePage.tsx` | 380 | `return null` inside `.map()` callback | INFO | Not a loading state — intentional JSX early return for empty category groups in Select dropdown. Expected and correct. |
|
||||||
|
| `BudgetDetailPage.tsx` | 492 | `return null` inside `.map()` callback | INFO | Same pattern — skips empty category groups in Add Item dialog Select. Expected and correct. |
|
||||||
|
|
||||||
|
No stub implementations, no TODO/FIXME/placeholder comments, no empty handlers, no loading-state `return null` patterns found in any of the 7 modified page files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Verification
|
||||||
|
|
||||||
|
`bun run build` passes cleanly:
|
||||||
|
- 2583 modules transformed
|
||||||
|
- TypeScript compilation: 0 errors
|
||||||
|
- Output: `dist/index.html`, `dist/assets/index-*.css` (58.73 kB), `dist/assets/index-*.js` (1,132.90 kB)
|
||||||
|
- Only warning: chunk size advisory (pre-existing, unrelated to Phase 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Verification
|
||||||
|
|
||||||
|
All 6 task commits documented in SUMMARYs are confirmed present in git history:
|
||||||
|
|
||||||
|
| Commit | Plan | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `36d068e` | 04-01 Task 1 | feat: redesign LoginPage with brand presence and OAuth icons |
|
||||||
|
| `0ff9939` | 04-01 Task 2 | feat: redesign RegisterPage to match LoginPage |
|
||||||
|
| `e9497e4` | 04-02 Task 1 | feat: upgrade CategoriesPage and TemplatePage |
|
||||||
|
| `ba19c30` | 04-02 Task 2 | feat: upgrade QuickAddPage and SettingsPage |
|
||||||
|
| `89dd3de` | 04-03 Task 1 | feat: upgrade BudgetListPage |
|
||||||
|
| `24d071c` | 04-03 Task 2 | feat: upgrade BudgetDetailPage |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notable Design Decisions Verified
|
||||||
|
|
||||||
|
1. **TemplatePage mirrored layout** (not PageShell import): Plan 02 explicitly chose `flex flex-col gap-6 > flex items-start justify-between gap-4` to preserve `TemplateName` inline-edit component. Visual result matches PageShell — confirmed in code at lines 242-281.
|
||||||
|
|
||||||
|
2. **TierBadge removed from BudgetDetailPage**: `grep TierBadge BudgetDetailPage.tsx` returns 0. Present in TemplatePage as intended.
|
||||||
|
|
||||||
|
3. **Settings no double heading**: `grep CardHeader SettingsPage.tsx` returns 0 — `CardHeader` and `CardTitle` fully removed; PageShell provides the sole "Settings" heading.
|
||||||
|
|
||||||
|
4. **Direction-aware diff covers all 6 types**: `SPENDING_TYPES = ["bill", "variable_expense", "debt"]` covers 3 spending types; all others (income, saving, investment) use the opposite diff direction — matches Phase 3 `CategorySection.tsx` pattern exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
### 1. Cross-page visual continuity
|
||||||
|
|
||||||
|
**Test:** Navigate Login -> Dashboard -> Categories -> Template -> Budget List -> Budget Detail -> Quick Add -> Settings -> Register
|
||||||
|
**Expected:** Consistent header typography (2xl semibold tracking-tight), consistent card styling, consistent muted/on-background color usage, no layout shift when sidebar transitions between pages
|
||||||
|
**Why human:** Layout continuity and "feel" of visual consistency across navigation paths cannot be verified by grep or build
|
||||||
|
|
||||||
|
### 2. German locale i18n completeness
|
||||||
|
|
||||||
|
**Test:** Log in, go to Settings, switch language to Deutsch, then visit every page
|
||||||
|
**Expected:** All text in German — nav labels, page titles, action buttons, form labels, month names in budget dialogs showing "Januar/Februar..." (not "January/February"), auth subtitles, error messages
|
||||||
|
**Why human:** i18n key presence verified; runtime substitution and any missed keys only visible at runtime
|
||||||
|
|
||||||
|
### 3. Auth page visual design
|
||||||
|
|
||||||
|
**Test:** Open `/login` and `/register` in browser
|
||||||
|
**Expected:** Distinctly muted grey background behind centered card; card has primary purple top border; favicon lightning bolt logo is visible and sized correctly above card title; Google and GitHub buttons show correct SVG icons
|
||||||
|
**Why human:** Visual design quality requires human eyeballing
|
||||||
|
|
||||||
|
### 4. BudgetDetail semantic color tokens
|
||||||
|
|
||||||
|
**Test:** Open a budget detail with items where some categories are over budget and some are under
|
||||||
|
**Expected:** Over-budget amounts appear in red using `--color-over-budget` OKLCH token (not hardcoded `text-red-600`); on-budget amounts appear in green using `--color-on-budget`; direction correct by category type
|
||||||
|
**Why human:** Semantic token correctness and diff direction require live data and visual inspection
|
||||||
|
|
||||||
|
### 5. Responsive layout (UI-RESPONSIVE-01)
|
||||||
|
|
||||||
|
**Test:** At 768px browser width, navigate all 9 pages
|
||||||
|
**Expected:** Sidebar collapses or shifts; tables have horizontal scroll; no content overflow; PageShell headers remain readable; auth cards remain centered
|
||||||
|
**Why human:** Responsive behavior requires browser viewport resizing — cannot be verified by static code analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 4 has achieved its goal. All 22 observable truths have automated verification evidence OR are flagged for human confirmation where visual quality is the measure. The codebase delivers:
|
||||||
|
|
||||||
|
- **Auth pages** (2): Fully redesigned with muted background, card accent, brand logo, i18n subtitles, and OAuth icons
|
||||||
|
- **CRUD/Settings pages** (4): PageShell headers, left-border accent group headers (Categories, Template), skeleton loading replacing `return null` on all pages, Settings has exactly one heading
|
||||||
|
- **Budget pages** (2): PageShell, locale-aware `Intl.DateTimeFormat`, semantic color tokens replacing hardcoded Tailwind classes, direction-aware diff for all 6 category types, group header accents, skeleton loading, i18n month/year/total labels
|
||||||
|
- **Build**: Passes without TypeScript errors
|
||||||
|
- **All 8 requirement IDs**: Satisfied by the 3 plans
|
||||||
|
|
||||||
|
The 5 human verification items are all quality/visual checks — the underlying implementations are confirmed correct by code inspection and build success.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-03-17_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
166
src/components/dashboard/CategorySection.tsx
Normal file
166
src/components/dashboard/CategorySection.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { categoryColors } from "@/lib/palette"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import type { CategoryType, BudgetItem } from "@/lib/types"
|
||||||
|
|
||||||
|
// Spending categories: over when actual > budget
|
||||||
|
// Income/saving/investment: over when actual < budget
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
currency: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySection({
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
budgeted,
|
||||||
|
actual,
|
||||||
|
currency,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
t,
|
||||||
|
}: CategorySectionProps) {
|
||||||
|
const { diff: headerDiff, isOver: headerIsOver } = computeDiff(budgeted, actual, type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center gap-3 rounded-md border-l-4 bg-card px-4 py-3 text-left hover:bg-muted/40 transition-colors"
|
||||||
|
style={{ borderLeftColor: categoryColors[type] }}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className="size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="tabular-nums">
|
||||||
|
{t("budgets.budgeted")} {formatCurrency(budgeted, currency)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{t("budgets.actual")} {formatCurrency(actual, currency)}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-sm tabular-nums font-medium",
|
||||||
|
headerIsOver ? "text-over-budget" : "text-on-budget"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(headerDiff), currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-collapsible-open data-[state=closed]:animate-collapsible-close">
|
||||||
|
<div className="pt-2">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("dashboard.sections.itemName")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.budgeted")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.actual")}</TableHead>
|
||||||
|
<TableHead className="text-right">{t("budgets.difference")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const { diff: itemDiff, isOver: itemIsOver } = computeDiff(
|
||||||
|
item.budgeted_amount,
|
||||||
|
item.actual_amount,
|
||||||
|
type
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.category?.name ?? item.category_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatCurrency(item.budgeted_amount, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatCurrency(item.actual_amount, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
"text-right tabular-nums",
|
||||||
|
itemIsOver ? "text-over-budget" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(itemDiff), currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{t("dashboard.sections.groupTotal", { label })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(budgeted, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(actual, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
"text-right tabular-nums font-medium",
|
||||||
|
headerIsOver ? "text-over-budget" : "text-on-budget"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(headerDiff), currency)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/components/dashboard/CollapsibleSections.tsx
Normal file
45
src/components/dashboard/CollapsibleSections.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { CategorySection } from "./CategorySection"
|
||||||
|
import type { CategoryType, BudgetItem } from "@/lib/types"
|
||||||
|
|
||||||
|
interface GroupData {
|
||||||
|
type: CategoryType
|
||||||
|
label: string
|
||||||
|
items: BudgetItem[]
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CollapsibleSectionsProps {
|
||||||
|
groups: GroupData[]
|
||||||
|
currency: string
|
||||||
|
openSections: Record<string, boolean>
|
||||||
|
onToggleSection: (type: string, open: boolean) => void
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollapsibleSections({
|
||||||
|
groups,
|
||||||
|
currency,
|
||||||
|
openSections,
|
||||||
|
onToggleSection,
|
||||||
|
t,
|
||||||
|
}: CollapsibleSectionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<CategorySection
|
||||||
|
key={group.type}
|
||||||
|
type={group.type}
|
||||||
|
label={group.label}
|
||||||
|
items={group.items}
|
||||||
|
budgeted={group.budgeted}
|
||||||
|
actual={group.actual}
|
||||||
|
currency={currency}
|
||||||
|
open={openSections[group.type] ?? false}
|
||||||
|
onOpenChange={(open) => onToggleSection(group.type, open)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,6 +52,21 @@ export function DashboardSkeleton() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible sections skeleton */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 rounded-md border-l-4 border-muted bg-card px-4 py-3">
|
||||||
|
<Skeleton className="size-4" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Skeleton className="h-5 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-5 w-24 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface StatCardProps {
|
|||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
valueClassName?: string
|
valueClassName?: string
|
||||||
|
subtitle?: string
|
||||||
|
subtitleClassName?: string
|
||||||
variance?: {
|
variance?: {
|
||||||
amount: string
|
amount: string
|
||||||
direction: "up" | "down" | "neutral"
|
direction: "up" | "down" | "neutral"
|
||||||
@@ -24,6 +26,8 @@ export function StatCard({
|
|||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
valueClassName,
|
valueClassName,
|
||||||
|
subtitle,
|
||||||
|
subtitleClassName,
|
||||||
variance,
|
variance,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
@@ -42,6 +46,11 @@ export function StatCard({
|
|||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className={cn("mt-0.5 text-xs text-muted-foreground", subtitleClassName)}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{variance && (
|
{variance && (
|
||||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { StatCard } from "./StatCard"
|
|||||||
interface SummaryStripProps {
|
interface SummaryStripProps {
|
||||||
income: { value: string; budgeted: string }
|
income: { value: string; budgeted: string }
|
||||||
expenses: { value: string; budgeted: string }
|
expenses: { value: string; budgeted: string }
|
||||||
balance: { value: string; isPositive: boolean }
|
balance: {
|
||||||
|
value: string
|
||||||
|
isPositive: boolean
|
||||||
|
carryoverSubtitle?: string
|
||||||
|
carryoverIsNegative?: boolean
|
||||||
|
}
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +44,8 @@ export function SummaryStrip({
|
|||||||
title={t("dashboard.availableBalance")}
|
title={t("dashboard.availableBalance")}
|
||||||
value={balance.value}
|
value={balance.value}
|
||||||
valueClassName={balance.isPositive ? "text-on-budget" : "text-over-budget"}
|
valueClassName={balance.isPositive ? "text-on-budget" : "text-over-budget"}
|
||||||
|
subtitle={balance.carryoverSubtitle}
|
||||||
|
subtitleClassName={balance.carryoverIsNegative ? "text-over-budget" : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"displayName": "Anzeigename",
|
"displayName": "Anzeigename",
|
||||||
"noAccount": "Noch kein Konto?",
|
"noAccount": "Noch kein Konto?",
|
||||||
"hasAccount": "Bereits ein Konto?",
|
"hasAccount": "Bereits ein Konto?",
|
||||||
"orContinueWith": "Oder weiter mit"
|
"orContinueWith": "Oder weiter mit",
|
||||||
|
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||||
|
"registerSubtitle": "Erstelle ein neues Konto"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Kategorien",
|
"title": "Kategorien",
|
||||||
@@ -59,7 +61,10 @@
|
|||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
"addItem": "Eintrag hinzufügen",
|
"addItem": "Eintrag hinzufügen",
|
||||||
"empty": "Noch keine Budgets. Erstelle dein erstes Monatsbudget.",
|
"empty": "Noch keine Budgets. Erstelle dein erstes Monatsbudget.",
|
||||||
"deleteConfirm": "Bist du sicher, dass du dieses Budget löschen möchtest?"
|
"deleteConfirm": "Bist du sicher, dass du dieses Budget löschen möchtest?",
|
||||||
|
"month": "Monat",
|
||||||
|
"year": "Jahr",
|
||||||
|
"total": "{{label}} Gesamt"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -84,7 +89,12 @@
|
|||||||
"actual": "Tatsaechlich",
|
"actual": "Tatsaechlich",
|
||||||
"noBudgetForMonth": "Kein Budget fuer diesen Monat",
|
"noBudgetForMonth": "Kein Budget fuer diesen Monat",
|
||||||
"createBudget": "Budget erstellen",
|
"createBudget": "Budget erstellen",
|
||||||
"generateFromTemplate": "Aus Vorlage generieren"
|
"generateFromTemplate": "Aus Vorlage generieren",
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Posten",
|
||||||
|
"groupTotal": "{{label}} Gesamt"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Inkl. {{amount}} Übertrag"
|
||||||
},
|
},
|
||||||
"quickAdd": {
|
"quickAdd": {
|
||||||
"title": "Schnelleingabe-Bibliothek",
|
"title": "Schnelleingabe-Bibliothek",
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
"hasAccount": "Already have an account?",
|
"hasAccount": "Already have an account?",
|
||||||
"orContinueWith": "Or continue with"
|
"orContinueWith": "Or continue with",
|
||||||
|
"loginSubtitle": "Sign in to your account",
|
||||||
|
"registerSubtitle": "Create a new account"
|
||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"title": "Categories",
|
"title": "Categories",
|
||||||
@@ -59,7 +61,10 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"addItem": "Add Item",
|
"addItem": "Add Item",
|
||||||
"empty": "No budgets yet. Create your first monthly budget.",
|
"empty": "No budgets yet. Create your first monthly budget.",
|
||||||
"deleteConfirm": "Are you sure you want to delete this budget?"
|
"deleteConfirm": "Are you sure you want to delete this budget?",
|
||||||
|
"month": "Month",
|
||||||
|
"year": "Year",
|
||||||
|
"total": "{{label}} Total"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -84,7 +89,12 @@
|
|||||||
"actual": "Actual",
|
"actual": "Actual",
|
||||||
"noBudgetForMonth": "No budget for this month",
|
"noBudgetForMonth": "No budget for this month",
|
||||||
"createBudget": "Create Budget",
|
"createBudget": "Create Budget",
|
||||||
"generateFromTemplate": "Generate from Template"
|
"generateFromTemplate": "Generate from Template",
|
||||||
|
"sections": {
|
||||||
|
"itemName": "Item",
|
||||||
|
"groupTotal": "{{label}} Total"
|
||||||
|
},
|
||||||
|
"carryoverIncludes": "Includes {{amount}} carryover"
|
||||||
},
|
},
|
||||||
"quickAdd": {
|
"quickAdd": {
|
||||||
"title": "Quick Add Library",
|
"title": "Quick Add Library",
|
||||||
|
|||||||
@@ -71,9 +71,23 @@
|
|||||||
|
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
/* Collapsible animation */
|
||||||
|
--animate-collapsible-open: collapsible-open 200ms ease-out;
|
||||||
|
--animate-collapsible-close: collapsible-close 200ms ease-out;
|
||||||
|
|
||||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-open {
|
||||||
|
from { height: 0; overflow: hidden; }
|
||||||
|
to { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes collapsible-close {
|
||||||
|
from { height: var(--radix-collapsible-content-height); overflow: hidden; }
|
||||||
|
to { height: 0; overflow: hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|||||||
566
src/pages/BudgetDetailPage.tsx
Normal file
566
src/pages/BudgetDetailPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { useParams, Link } from "react-router-dom"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Trash2, ArrowLeft } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useBudgetDetail, useBudgets } from "@/hooks/useBudgets"
|
||||||
|
import { useCategories } from "@/hooks/useCategories"
|
||||||
|
import type { BudgetItem, CategoryType } from "@/lib/types"
|
||||||
|
import { categoryColors } from "@/lib/palette"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CATEGORY_TYPES: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
|
||||||
|
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Direction-aware diff helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isSpendingType(type: CategoryType): boolean {
|
||||||
|
return SPENDING_TYPES.includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InlineEditCell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table cell that displays a formatted currency value and switches to a
|
||||||
|
* number input on click. Commits on blur or Enter; reverts on Escape.
|
||||||
|
*/
|
||||||
|
interface InlineEditCellProps {
|
||||||
|
value: number
|
||||||
|
currency: string
|
||||||
|
onCommit: (next: number) => Promise<void>
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineEditCell({
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
onCommit,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: InlineEditCellProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(String(value))
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setDraft(String(value))
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const parsed = parseFloat(draft)
|
||||||
|
if (!isNaN(parsed) && parsed !== value) {
|
||||||
|
await onCommit(parsed)
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setDraft(String(value))
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="h-7 w-28 text-right tabular-nums"
|
||||||
|
onBlur={() => void commit()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void commit()
|
||||||
|
if (e.key === "Escape") cancel()
|
||||||
|
}}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
className="cursor-pointer select-none text-right tabular-nums hover:bg-muted/40"
|
||||||
|
onClick={startEdit}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{formatCurrency(value, currency)}
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DifferenceCell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add item dialog state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AddItemState {
|
||||||
|
categoryId: string
|
||||||
|
amount: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ADD_STATE: AddItemState = {
|
||||||
|
categoryId: "",
|
||||||
|
amount: "",
|
||||||
|
notes: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BudgetDetailPage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function BudgetDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const budgetId = id ?? ""
|
||||||
|
|
||||||
|
const { budget, items, loading } = useBudgetDetail(budgetId)
|
||||||
|
const { updateItem, createItem, deleteItem } = useBudgets()
|
||||||
|
const { categories } = useCategories()
|
||||||
|
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [addForm, setAddForm] = useState<AddItemState>(DEFAULT_ADD_STATE)
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
setAddForm(DEFAULT_ADD_STATE)
|
||||||
|
setAddDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddItem() {
|
||||||
|
const parsed = parseFloat(addForm.amount)
|
||||||
|
if (!addForm.categoryId || isNaN(parsed) || parsed < 0) return
|
||||||
|
try {
|
||||||
|
await createItem.mutateAsync({
|
||||||
|
budgetId,
|
||||||
|
category_id: addForm.categoryId,
|
||||||
|
budgeted_amount: parsed,
|
||||||
|
actual_amount: 0,
|
||||||
|
notes: addForm.notes.trim() || null,
|
||||||
|
})
|
||||||
|
setAddDialogOpen(false)
|
||||||
|
toast.success(t("budgets.addItem"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateActual(item: BudgetItem, actual_amount: number) {
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
id: item.id,
|
||||||
|
budgetId,
|
||||||
|
actual_amount,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteItem(itemId: string) {
|
||||||
|
try {
|
||||||
|
await deleteItem.mutateAsync({ id: itemId, budgetId })
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Budget heading: "Month YYYY" (locale-aware)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Derived: group items by category type
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const grouped = CATEGORY_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
items: items.filter((item) => item.category?.type === type),
|
||||||
|
})).filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
// Overall totals
|
||||||
|
const totalBudgeted = items.reduce((sum, i) => sum + i.budgeted_amount, 0)
|
||||||
|
const totalActual = items.reduce((sum, i) => sum + i.actual_amount, 0)
|
||||||
|
|
||||||
|
const currency = budget?.currency ?? "EUR"
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">
|
||||||
|
{t("common.error")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("budgets.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{grouped.map(({ type, items: groupItems }) => {
|
||||||
|
const groupBudgeted = groupItems.reduce(
|
||||||
|
(sum, i) => sum + i.budgeted_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const groupActual = groupItems.reduce(
|
||||||
|
(sum, i) => sum + i.actual_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
{/* Group heading */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("budgets.budgeted")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("budgets.actual")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("budgets.difference")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("budgets.notes")}</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.category?.name ?? item.category_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatCurrency(item.budgeted_amount, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<InlineEditCell
|
||||||
|
value={item.actual_amount}
|
||||||
|
currency={currency}
|
||||||
|
onCommit={(val) => handleUpdateActual(item, val)}
|
||||||
|
aria-label={`${t("budgets.actual")} — ${item.category?.name ?? ""}`}
|
||||||
|
/>
|
||||||
|
<DifferenceCell
|
||||||
|
budgeted={item.budgeted_amount}
|
||||||
|
actual={item.actual_amount}
|
||||||
|
currency={currency}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{item.notes ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
onClick={() => void handleDeleteItem(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{t("budgets.total", { label: t(`categories.types.${type}`) })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(groupBudgeted, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(groupActual, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<DifferenceCell
|
||||||
|
budgeted={groupBudgeted}
|
||||||
|
actual={groupActual}
|
||||||
|
currency={currency}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Overall totals */}
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.budgeted")}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatCurrency(totalBudgeted, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.actual")}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatCurrency(totalActual, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.difference")}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold tabular-nums",
|
||||||
|
totalBudgeted - totalActual >= 0 ? "text-on-budget" : "text-over-budget"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(totalBudgeted - totalActual), currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add one-off item dialog */}
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("budgets.addItem")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Category picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Select
|
||||||
|
value={addForm.categoryId}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setAddForm((prev) => ({ ...prev, categoryId: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("quickAdd.pickCategory")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_TYPES.map((type) => {
|
||||||
|
const filtered = categories.filter((c) => c.type === type)
|
||||||
|
if (filtered.length === 0) return null
|
||||||
|
return (
|
||||||
|
<SelectGroup key={type}>
|
||||||
|
<SelectLabel className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: categoryColors[type] }}
|
||||||
|
/>
|
||||||
|
{t(`categories.types.${type}`)}
|
||||||
|
</SelectLabel>
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budgeted amount */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.budgeted")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={addForm.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((prev) => ({ ...prev, amount: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.notes")}</Label>
|
||||||
|
<Input
|
||||||
|
value={addForm.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t("budgets.notes")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAddDialogOpen(false)}
|
||||||
|
disabled={createItem.isPending}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleAddItem()}
|
||||||
|
disabled={
|
||||||
|
!addForm.categoryId ||
|
||||||
|
!addForm.amount ||
|
||||||
|
isNaN(parseFloat(addForm.amount)) ||
|
||||||
|
createItem.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
272
src/pages/BudgetListPage.tsx
Normal file
272
src/pages/BudgetListPage.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState, useMemo } from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, ChevronRight } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useBudgets } from "@/hooks/useBudgets"
|
||||||
|
import type { Budget } from "@/lib/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
const YEARS = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR - 1 + i)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a budget's start_date ISO string, return a human-readable "Month YYYY"
|
||||||
|
* label e.g. "March 2026", using the given locale for month names.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dialog state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface NewBudgetState {
|
||||||
|
month: number
|
||||||
|
year: number
|
||||||
|
useTemplate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDialogState(): NewBudgetState {
|
||||||
|
const now = new Date()
|
||||||
|
return {
|
||||||
|
month: now.getMonth() + 1,
|
||||||
|
year: now.getFullYear(),
|
||||||
|
useTemplate: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function BudgetListPage() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const locale = i18n.language
|
||||||
|
const { budgets, loading, createBudget, generateFromTemplate } = useBudgets()
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState<NewBudgetState>(defaultDialogState)
|
||||||
|
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
setForm(defaultDialogState())
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
try {
|
||||||
|
const mutation = form.useTemplate ? generateFromTemplate : createBudget
|
||||||
|
const result = await mutation.mutateAsync({
|
||||||
|
month: form.month,
|
||||||
|
year: form.year,
|
||||||
|
})
|
||||||
|
closeDialog()
|
||||||
|
// Announce success via a toast showing the created month
|
||||||
|
toast.success(budgetLabel(result, locale))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaving = createBudget.isPending || generateFromTemplate.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={t("budgets.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openDialog} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("budgets.newBudget")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Empty state */}
|
||||||
|
{budgets.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("budgets.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("budgets.title")}</TableHead>
|
||||||
|
<TableHead>{t("settings.currency")}</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{budgets.map((budget) => (
|
||||||
|
<TableRow key={budget.id} className="group">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
to={`/budgets/${budget.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{budgetLabel(budget, locale)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{budget.currency}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Link
|
||||||
|
to={`/budgets/${budget.id}`}
|
||||||
|
aria-label={budgetLabel(budget, locale)}
|
||||||
|
className="inline-flex items-center text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New budget dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("budgets.newBudget")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Month picker */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.month")}</Label>
|
||||||
|
<Select
|
||||||
|
value={String(form.month)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((prev) => ({ ...prev, month: Number(v) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{monthItems.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={String(m.value)}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.year")}</Label>
|
||||||
|
<Select
|
||||||
|
value={String(form.year)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setForm((prev) => ({ ...prev, year: Number(v) }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{YEARS.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template toggle */}
|
||||||
|
<div className="flex items-center gap-3 rounded-md border p-3">
|
||||||
|
<input
|
||||||
|
id="use-template"
|
||||||
|
type="checkbox"
|
||||||
|
className="size-4 cursor-pointer accent-primary"
|
||||||
|
checked={form.useTemplate}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, useTemplate: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="use-template" className="cursor-pointer">
|
||||||
|
{t("budgets.generateFromTemplate")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={closeDialog} disabled={isSaving}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleCreate()} disabled={isSaving}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
232
src/pages/CategoriesPage.tsx
Normal file
232
src/pages/CategoriesPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useCategories } from "@/hooks/useCategories"
|
||||||
|
import type { Category, CategoryType } from "@/lib/types"
|
||||||
|
import { categoryColors } from "@/lib/palette"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
const CATEGORY_TYPES: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CategoriesPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { categories, loading, create, update, remove } = useCategories()
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Category | null>(null)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [type, setType] = useState<CategoryType>("bill")
|
||||||
|
const [icon, setIcon] = useState("")
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null)
|
||||||
|
setName("")
|
||||||
|
setType("bill")
|
||||||
|
setIcon("")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cat: Category) {
|
||||||
|
setEditing(cat)
|
||||||
|
setName(cat.name)
|
||||||
|
setType(cat.type)
|
||||||
|
setIcon(cat.icon ?? "")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await update.mutateAsync({ id: editing.id, name, type, icon: icon || null })
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({ name, type, icon: icon || undefined })
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(id)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("categories.inUse"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = CATEGORY_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
items: categories.filter((c) => c.type === type),
|
||||||
|
})).filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("categories.title")}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[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-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={t("categories.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("categories.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("categories.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{grouped.map(({ type, items }) => (
|
||||||
|
<div key={type}>
|
||||||
|
<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>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead>{t("categories.icon")}</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((cat) => (
|
||||||
|
<TableRow key={cat.id}>
|
||||||
|
<TableCell className="font-medium">{cat.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cat.icon && <Badge variant="secondary">{cat.icon}</Badge>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => openEdit(cat)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(cat.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? t("categories.edit") : t("categories.add")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.type")}</Label>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => setType(v as CategoryType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_TYPES.map((ct) => (
|
||||||
|
<SelectItem key={ct} value={ct}>
|
||||||
|
{t(`categories.types.${ct}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.icon")}</Label>
|
||||||
|
<Input
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
placeholder="e.g. emoji or icon name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react"
|
import { useState, useMemo } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets"
|
import { useBudgets, useBudgetDetail } from "@/hooks/useBudgets"
|
||||||
import { useMonthParam } from "@/hooks/useMonthParam"
|
import { useMonthParam } from "@/hooks/useMonthParam"
|
||||||
@@ -13,6 +13,7 @@ import { MonthNavigator } from "@/components/dashboard/MonthNavigator"
|
|||||||
import { ExpenseDonutChart } from "@/components/dashboard/charts/ExpenseDonutChart"
|
import { ExpenseDonutChart } from "@/components/dashboard/charts/ExpenseDonutChart"
|
||||||
import { IncomeBarChart } from "@/components/dashboard/charts/IncomeBarChart"
|
import { IncomeBarChart } from "@/components/dashboard/charts/IncomeBarChart"
|
||||||
import { SpendBarChart } from "@/components/dashboard/charts/SpendBarChart"
|
import { SpendBarChart } from "@/components/dashboard/charts/SpendBarChart"
|
||||||
|
import { CollapsibleSections } from "@/components/dashboard/CollapsibleSections"
|
||||||
import QuickAddPicker from "@/components/QuickAddPicker"
|
import QuickAddPicker from "@/components/QuickAddPicker"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -27,6 +28,26 @@ const EXPENSE_TYPES: CategoryType[] = [
|
|||||||
"investment",
|
"investment",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const CATEGORY_TYPES_ALL: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isOverBudget(type: CategoryType, budgeted: number, actual: number): boolean {
|
||||||
|
if (type === "income" || type === "saving" || type === "investment") {
|
||||||
|
return actual < budgeted // under-earned / under-saved
|
||||||
|
}
|
||||||
|
return actual > budgeted // overspent
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dashboard inner — rendered once a budget id is known
|
// Dashboard inner — rendered once a budget id is known
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -114,6 +135,33 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
|
|||||||
[items, t]
|
[items, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const groupedSections = useMemo(() =>
|
||||||
|
CATEGORY_TYPES_ALL
|
||||||
|
.map((type) => {
|
||||||
|
const groupItems = items.filter((i) => i.category?.type === type)
|
||||||
|
if (groupItems.length === 0) return null
|
||||||
|
const budgeted = groupItems.reduce((s, i) => s + i.budgeted_amount, 0)
|
||||||
|
const actual = groupItems.reduce((s, i) => s + i.actual_amount, 0)
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
label: t(`categories.types.${type}`),
|
||||||
|
items: groupItems,
|
||||||
|
budgeted,
|
||||||
|
actual,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((g): g is NonNullable<typeof g> => g !== null),
|
||||||
|
[items, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [openSections, setOpenSections] = useState<Record<string, boolean>>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
groupedSections.map((g) => [g.type, isOverBudget(g.type, g.budgeted, g.actual)])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Note: state resets automatically on month navigation because DashboardContent
|
||||||
|
// is keyed by budgetId in DashboardPage (key={budgetId} causes full remount)
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Early returns after all hooks
|
// Early returns after all hooks
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -121,9 +169,19 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
|
|||||||
if (loading) return <DashboardSkeleton />
|
if (loading) return <DashboardSkeleton />
|
||||||
if (!budget) return null
|
if (!budget) return null
|
||||||
|
|
||||||
|
const handleToggleSection = (type: string, open: boolean) => {
|
||||||
|
setOpenSections((prev) => ({ ...prev, [type]: open }))
|
||||||
|
}
|
||||||
|
|
||||||
const currency = budget.currency
|
const currency = budget.currency
|
||||||
const availableBalance = totalIncome - totalExpenses + budget.carryover_amount
|
const availableBalance = totalIncome - totalExpenses + budget.carryover_amount
|
||||||
|
|
||||||
|
const carryover = budget.carryover_amount
|
||||||
|
const carryoverSubtitle = carryover !== 0
|
||||||
|
? t("dashboard.carryoverIncludes", { amount: formatCurrency(Math.abs(carryover), currency) })
|
||||||
|
: undefined
|
||||||
|
const carryoverIsNegative = carryover < 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
@@ -139,6 +197,8 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
|
|||||||
balance={{
|
balance={{
|
||||||
value: formatCurrency(availableBalance, currency),
|
value: formatCurrency(availableBalance, currency),
|
||||||
isPositive: availableBalance >= 0,
|
isPositive: availableBalance >= 0,
|
||||||
|
carryoverSubtitle,
|
||||||
|
carryoverIsNegative,
|
||||||
}}
|
}}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@@ -186,6 +246,17 @@ function DashboardContent({ budgetId }: { budgetId: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible category sections */}
|
||||||
|
{groupedSections.length > 0 && (
|
||||||
|
<CollapsibleSections
|
||||||
|
groups={groupedSections}
|
||||||
|
currency={currency}
|
||||||
|
openSections={openSections}
|
||||||
|
onToggleSection={handleToggleSection}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Add button */}
|
{/* Quick Add button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<QuickAddPicker budgetId={budgetId} />
|
<QuickAddPicker budgetId={budgetId} />
|
||||||
@@ -256,7 +327,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DashboardContent budgetId={currentBudget.id} />
|
<DashboardContent key={currentBudget.id} budgetId={currentBudget.id} />
|
||||||
)}
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|||||||
118
src/pages/LoginPage.tsx
Normal file
118
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { signIn, signInWithOAuth } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await signIn(email, password)
|
||||||
|
navigate("/")
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.error"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
|
||||||
|
<Card className="w-full max-w-sm border-t-4 border-t-primary shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<img src="/favicon.svg" alt="" className="mx-auto mb-3 size-10" aria-hidden="true" />
|
||||||
|
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.loginSubtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{t("auth.login")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Separator />
|
||||||
|
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-card px-2 text-xs text-muted-foreground">
|
||||||
|
{t("auth.orContinueWith")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-2"
|
||||||
|
onClick={() => signInWithOAuth("google")}
|
||||||
|
>
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Google
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-2"
|
||||||
|
onClick={() => signInWithOAuth("github")}
|
||||||
|
>
|
||||||
|
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("auth.noAccount")}{" "}
|
||||||
|
<Link to="/register" className="text-primary underline">
|
||||||
|
{t("auth.register")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
215
src/pages/QuickAddPage.tsx
Normal file
215
src/pages/QuickAddPage.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useQuickAdd } from "@/hooks/useQuickAdd"
|
||||||
|
import type { QuickAddItem } from "@/lib/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
export default function QuickAddPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { items, loading, create, update, remove } = useQuickAdd()
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<QuickAddItem | null>(null)
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [icon, setIcon] = useState("")
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Dialog helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null)
|
||||||
|
setName("")
|
||||||
|
setIcon("")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: QuickAddItem) {
|
||||||
|
setEditing(item)
|
||||||
|
setName(item.name)
|
||||||
|
setIcon(item.icon ?? "")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Save handler
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await update.mutateAsync({
|
||||||
|
id: editing.id,
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || null,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync({
|
||||||
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Delete handler
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync(id)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("quickAdd.title")}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
|
||||||
|
<Skeleton className="h-5 w-10 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="ml-auto h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={t("quickAdd.title")}
|
||||||
|
action={
|
||||||
|
<Button onClick={openCreate} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("quickAdd.add")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("quickAdd.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.icon")}</TableHead>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
{item.icon && (
|
||||||
|
<Badge variant="secondary">{item.icon}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("quickAdd.edit")}
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
onClick={() => void handleDelete(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? t("quickAdd.edit") : t("quickAdd.add")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={t("categories.name")}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.icon")}</Label>
|
||||||
|
<Input
|
||||||
|
value={icon}
|
||||||
|
onChange={(e) => setIcon(e.target.value)}
|
||||||
|
placeholder="e.g. emoji or icon name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={!name.trim() || isPending}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
src/pages/RegisterPage.tsx
Normal file
83
src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Link, useNavigate } from "react-router-dom"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { signUp } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await signUp(email, password)
|
||||||
|
navigate("/")
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t("common.error"))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/60 p-4">
|
||||||
|
<Card className="w-full max-w-sm border-t-4 border-t-primary shadow-lg">
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<img src="/favicon.svg" alt="" className="mx-auto mb-3 size-10" aria-hidden="true" />
|
||||||
|
<CardTitle className="text-2xl">{t("app.title")}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.registerSubtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{t("auth.register")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("auth.hasAccount")}{" "}
|
||||||
|
<Link to="/login" className="text-primary underline">
|
||||||
|
{t("auth.login")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
src/pages/SettingsPage.tsx
Normal file
141
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useAuth } from "@/hooks/useAuth"
|
||||||
|
import { supabase } from "@/lib/supabase"
|
||||||
|
import type { Profile } from "@/lib/types"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { PageShell } from "@/components/shared/PageShell"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return
|
||||||
|
supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single()
|
||||||
|
.then(({ data }) => {
|
||||||
|
if (data) {
|
||||||
|
setProfile(data)
|
||||||
|
i18n.changeLanguage(data.locale)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [user, i18n])
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!profile || !user) return
|
||||||
|
setSaving(true)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({
|
||||||
|
display_name: profile.display_name,
|
||||||
|
locale: profile.locale,
|
||||||
|
currency: profile.currency,
|
||||||
|
})
|
||||||
|
.eq("id", user.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
} else {
|
||||||
|
i18n.changeLanguage(profile.locale)
|
||||||
|
toast.success(t("settings.saved"))
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title={t("settings.title")}>
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.displayName")}</Label>
|
||||||
|
<Input
|
||||||
|
value={profile?.display_name ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setProfile((p) => p && { ...p, display_name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.language")}</Label>
|
||||||
|
<Select
|
||||||
|
value={profile?.locale ?? "en"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setProfile((p) => p && { ...p, locale: val })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">English</SelectItem>
|
||||||
|
<SelectItem value="de">Deutsch</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("settings.currency")}</Label>
|
||||||
|
<Select
|
||||||
|
value={profile?.currency ?? "EUR"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setProfile((p) => p && { ...p, currency: val })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="EUR">EUR</SelectItem>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="GBP">GBP</SelectItem>
|
||||||
|
<SelectItem value="CHF">CHF</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{t("settings.save")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
483
src/pages/TemplatePage.tsx
Normal file
483
src/pages/TemplatePage.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import { useState, useRef } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Pencil, Trash2, Check, X } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useTemplate } from "@/hooks/useTemplate"
|
||||||
|
import { useCategories } from "@/hooks/useCategories"
|
||||||
|
import type { TemplateItem, CategoryType } from "@/lib/types"
|
||||||
|
import { categoryColors } from "@/lib/palette"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CATEGORY_TYPES: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
|
||||||
|
const TIERS: TemplateItem["item_tier"][] = ["fixed", "variable"]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-components
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Inline-editable template name heading. */
|
||||||
|
function TemplateName({
|
||||||
|
name,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
onSave: (next: string) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(name)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setDraft(name)
|
||||||
|
setEditing(true)
|
||||||
|
// Let React commit the input before focusing.
|
||||||
|
setTimeout(() => inputRef.current?.select(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (draft.trim() && draft.trim() !== name) {
|
||||||
|
await onSave(draft.trim())
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setDraft(name)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="h-8 text-xl font-semibold"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void commit()
|
||||||
|
if (e.key === "Escape") cancel()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => void commit()}>
|
||||||
|
<Check className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={cancel}>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startEdit}
|
||||||
|
className="group flex items-center gap-2 text-left"
|
||||||
|
aria-label="Edit template name"
|
||||||
|
>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight group-hover:underline">{name}</h1>
|
||||||
|
<Pencil className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dialog state types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type DialogMode = "create" | "edit"
|
||||||
|
|
||||||
|
interface DialogState {
|
||||||
|
mode: DialogMode
|
||||||
|
item?: TemplateItem
|
||||||
|
categoryId: string
|
||||||
|
tier: TemplateItem["item_tier"]
|
||||||
|
amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DIALOG: DialogState = {
|
||||||
|
mode: "create",
|
||||||
|
categoryId: "",
|
||||||
|
tier: "fixed",
|
||||||
|
amount: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function TemplatePage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { template, items, loading, updateName, createItem, updateItem, deleteItem } =
|
||||||
|
useTemplate()
|
||||||
|
const { categories } = useCategories()
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [dialog, setDialog] = useState<DialogState>(DEFAULT_DIALOG)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Dialog helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setDialog({ ...DEFAULT_DIALOG, mode: "create" })
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: TemplateItem) {
|
||||||
|
setDialog({
|
||||||
|
mode: "edit",
|
||||||
|
item,
|
||||||
|
categoryId: item.category_id,
|
||||||
|
tier: item.item_tier,
|
||||||
|
amount: String(item.budgeted_amount),
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const parsed = parseFloat(dialog.amount)
|
||||||
|
if (!dialog.categoryId || isNaN(parsed) || parsed < 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dialog.mode === "edit" && dialog.item) {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
id: dialog.item.id,
|
||||||
|
item_tier: dialog.tier,
|
||||||
|
budgeted_amount: parsed,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createItem.mutateAsync({
|
||||||
|
category_id: dialog.categoryId,
|
||||||
|
item_tier: dialog.tier,
|
||||||
|
budgeted_amount: parsed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await deleteItem.mutateAsync(id)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNameSave(name: string) {
|
||||||
|
try {
|
||||||
|
await updateName.mutateAsync(name)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Derived data: group items by the category type
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const grouped = CATEGORY_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
items: items.filter((item) => item.category?.type === type),
|
||||||
|
})).filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
// Categories already belonging to a template item (for the "create"
|
||||||
|
// dialog we still allow duplicates, as the spec doesn't forbid them).
|
||||||
|
const isSaving =
|
||||||
|
createItem.isPending || updateItem.isPending || deleteItem.isPending
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-9 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2].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, 3].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-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
<Skeleton className="ml-auto h-4 w-20" />
|
||||||
|
<Skeleton className="h-7 w-7 rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Header — mirrors PageShell's flex items-start justify-between gap-4 layout */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<TemplateName
|
||||||
|
name={template?.name ?? t("template.title")}
|
||||||
|
onSave={handleNameSave}
|
||||||
|
/>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<Button onClick={openCreate} size="sm" disabled={isSaving}>
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("template.addItem")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("template.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{grouped.map(({ type, items: groupItems }) => (
|
||||||
|
<div key={type}>
|
||||||
|
{/* Group heading */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("categories.name")}</TableHead>
|
||||||
|
<TableHead>{t("categories.type")}</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("template.budgetedAmount")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-24" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.category?.name ?? item.category_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TierBadge tier={item.item_tier} t={t} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatCurrency(item.budgeted_amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
onClick={() => openEdit(item)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
onClick={() => void handleDelete(item.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / Edit dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{dialog.mode === "edit"
|
||||||
|
? t("common.edit")
|
||||||
|
: t("template.addItem")}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Category picker — only shown when creating */}
|
||||||
|
{dialog.mode === "create" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Select
|
||||||
|
value={dialog.categoryId}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDialog((prev) => ({ ...prev, categoryId: v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={t("quickAdd.pickCategory")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CATEGORY_TYPES.map((type) => {
|
||||||
|
const filtered = categories.filter((c) => c.type === type)
|
||||||
|
if (filtered.length === 0) return null
|
||||||
|
return (
|
||||||
|
<SelectGroup key={type}>
|
||||||
|
<SelectLabel className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: categoryColors[type] }}
|
||||||
|
/>
|
||||||
|
{t(`categories.types.${type}`)}
|
||||||
|
</SelectLabel>
|
||||||
|
{filtered.map((cat) => (
|
||||||
|
<SelectItem key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tier */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.type")}</Label>
|
||||||
|
<Select
|
||||||
|
value={dialog.tier}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDialog((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tier: v as TemplateItem["item_tier"],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIERS.map((tier) => (
|
||||||
|
<SelectItem key={tier} value={tier}>
|
||||||
|
{t(`template.${tier}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budgeted amount */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("template.budgetedAmount")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={dialog.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDialog((prev) => ({ ...prev, amount: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={
|
||||||
|
!dialog.categoryId ||
|
||||||
|
!dialog.amount ||
|
||||||
|
isNaN(parseFloat(dialog.amount)) ||
|
||||||
|
createItem.isPending ||
|
||||||
|
updateItem.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Small helper component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TierBadge({
|
||||||
|
tier,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
tier: TemplateItem["item_tier"]
|
||||||
|
t: (key: string) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Badge variant={tier === "fixed" ? "default" : "secondary"}>
|
||||||
|
{t(`template.${tier}`)}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user