Compare commits

...

6 Commits

Author SHA1 Message Date
1ea119d2a4 docs: create milestone v2.0 roadmap (5 phases) 2026-04-02 16:09:19 +02:00
0b02d7d328 docs: define milestone v2.0 requirements 2026-04-02 15:59:43 +02:00
0b72f1c305 docs: complete project research 2026-04-02 15:30:56 +02:00
8fdab4b796 docs: start milestone v2.0 UX Simplification & Design Rework 2026-04-02 15:16:10 +02:00
4b0f18a216 Merge branch 'Develop' of https://gitea.jeanlucmakiola.de/makiolaj/SimpleFinanceDash into Develop
# Conflicts:
#	.planning/milestones/v1.0-phases/02-dashboard-charts-and-layout/02-01-SUMMARY.md
#	.planning/milestones/v1.0-phases/03-collapsible-dashboard-sections/03-CONTEXT.md
2026-04-02 14:33:13 +02:00
039fa0bc80 sum 2026-04-02 14:29:36 +02:00
64 changed files with 6475 additions and 1280 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://your-supabase-instance.example.com
VITE_SUPABASE_ANON_KEY=your-anon-key-here

186
.gitignore vendored
View File

@@ -1,181 +1,33 @@
# Binaries for programs and plugins # Dependencies
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/ node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Build output
web_modules/ dist/
# TypeScript cache # Environment
*.tsbuildinfo .env
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.* .env.*
!.env.example !.env.example
# parcel-bundler cache (https://parceljs.org/) # Vite
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
.pnpm-store
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
.vite/ .vite/
references/ # TypeScript
*.tsbuildinfo
.idea # IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store .DS_Store
# Go binary output # Logs
/server *.log
/backend/server
# Build artifacts (embed directories populated at build time) # Test
backend/cmd/server/frontend_dist/ coverage/
backend/cmd/server/migrations/
# Lock files are committed (bun.lock)

View File

@@ -8,18 +8,24 @@ A self-hosted personal budget dashboard that replaces a manual spreadsheet workf
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. 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 Milestone: v2.0 UX Simplification & Design Rework
**Goal:** Make the app intuitive and visually clean — template-driven budgeting with a sharp, minimal, pastel design system.
**Target features:**
- Design system overhaul — sharp edges, clear pastels, minimal whitespace-driven layout
- Wizard-style template setup with pre-filled common items (rent, car insurance, groceries, etc.)
- Category library with smart defaults and sensible pre-loaded amounts
- Auto-created monthly budgets from template on first visit (no prompts)
- Simplified budget view — add one-offs from category library directly (replaces quick-add page)
- Dashboard = this month's budget at a glance (not chart overload)
- Dashboard data fixes — spending/budget data correctly reflects user input
## Current State ## Current State
**Shipped:** v1.0 (UI/UX Overhaul) + prior foundation (auth, CRUD, templates, quick-add) **Shipped:** v1.0 (UI/UX Overhaul) + prior foundation (auth, CRUD, templates, quick-add)
The app has a complete visual overhaul: The app has a visual overhaul but the UX is poor — too many disconnected concepts (categories, templates, budgets, quick-add), the design feels clunky with excessive rounding, and the dashboard doesn't clearly show budget data. v2.0 simplifies the mental model to two concepts: "my template" and "this month's budget."
- 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
@@ -53,13 +59,18 @@ The app has a complete visual overhaul:
### Active ### Active
<!-- Next milestone scope. TBD. --> <!-- v2.0 scope — defined during milestone kickoff -->
(No active requirements — next milestone not yet planned) - [ ] Design system: sharp edges, clear pastels, minimal layout
- [ ] Wizard-style template setup with smart defaults
- [ ] Category library with pre-loaded common items
- [ ] Auto-created monthly budgets from template
- [ ] Simplified budget view with inline add-from-library
- [ ] Dashboard shows current month's budget clearly
- [ ] Dashboard data correctly reflects entered spending
### Out of Scope ### Out of Scope
- New backend features or data model changes — UI milestone complete, future milestones may add backend
- Recurring transaction automation — future feature - Recurring transaction automation — future feature
- Spending alerts or notifications — future feature - Spending alerts or notifications — future feature
- Trend charts over months — future feature - Trend charts over months — future feature
@@ -70,9 +81,11 @@ The app has a complete visual overhaul:
## Context ## Context
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. v1.0 shipped a visual overhaul but the UX remains problematic. The user model has too many disconnected concepts — categories, templates, budgets, and quick-add are all separate pages with unclear relationships. The design system has excessive rounding and colors that aren't truly pastel. The dashboard shows charts but doesn't clearly reflect budget data the user entered.
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. v2.0 simplifies to two user-facing concepts: "my template" (recurring budget structure with smart defaults) and "this month's budget" (auto-created from template, track actuals, add one-offs from library). The design shifts to sharp, minimal, clean pastels.
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. Backend changes are in scope for this milestone where needed to support auto-creation and template-driven flow.
## Constraints ## Constraints
@@ -100,6 +113,10 @@ Tech stack: Go 1.25 + React 19 + TypeScript + Tailwind CSS 4 + shadcn/ui + Recha
| CategorySection controlled open/onOpenChange pattern | Parent owns state for smart expand defaults and month-change reset | ✓ 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 | | 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 | | Direction-aware diff for all 6 category types | Spending over when actual > budget; income/saving over when actual < budget | ✓ Good |
| Simplify to 2 user concepts: template + monthly budget | Categories/templates/budgets/quick-add too disconnected; users don't understand the flow | — Pending |
| Sharp minimal design with clear pastels | v1.0 rounded/clunky design doesn't match the "clean spreadsheet" vision | — Pending |
| Auto-create budgets from template on month visit | Removes friction; template is the source of truth | — Pending |
| Wizard-style template setup with smart defaults | First-run must be intuitive; pre-filled common items reduce setup time | — Pending |
## Evolution ## Evolution
@@ -119,4 +136,4 @@ This document evolves at phase transitions and milestone boundaries.
4. Update Context with current state 4. Update Context with current state
--- ---
*Last updated: 2026-03-24 after v1.0 UI/UX Overhaul milestone completion* *Last updated: 2026-04-02 after v2.0 milestone kickoff*

105
.planning/REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,105 @@
# Requirements: SimpleFinanceDash
**Defined:** 2026-04-02
**Core Value:** 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.
## v2.0 Requirements
Requirements for UX Simplification & Design Rework. Each maps to roadmap phases.
### Design System
- [ ] **DS-01**: User sees sharp-edged UI across all pages (no rounded corners)
- [ ] **DS-02**: User sees clear pastel colors that are visibly colorful, not washed out
- [ ] **DS-03**: User sees a clean, minimal layout with generous whitespace
### Setup Wizard
- [ ] **SETUP-01**: New user is guided through a 3-step wizard: income → recurring items → review
- [ ] **SETUP-02**: User sees pre-filled common budget items with sensible default amounts (~15-20 items: rent, groceries, car insurance, utilities, etc.)
- [ ] **SETUP-03**: User can skip any wizard step or the entire wizard
- [ ] **SETUP-04**: User sees a live "remaining to allocate" balance updating as items are selected in the wizard
- [ ] **SETUP-05**: User's template is created from wizard selections on completion
### Auto-Budget
- [ ] **AUTO-01**: User's monthly budget is auto-created from template when visiting a month for the first time
- [ ] **AUTO-02**: User sees a toast notification on the first auto-creation only
- [ ] **AUTO-03**: Auto-creation uses the user's configured currency, not a hardcoded default
### Budget View
- [ ] **BV-01**: User can add one-off items to the monthly budget via an inline sheet panel (from category library)
- [ ] **BV-02**: Quick Add page is removed from navigation
- [ ] **BV-03**: User sees empty state with clear CTA when template, budget, or dashboard is empty
### Dashboard
- [ ] **DASH-01**: Dashboard correctly displays budget and spending data the user entered
- [ ] **DASH-02**: Dashboard shows this month's budget at a glance (simplified view, not chart overload)
- [ ] **DASH-03**: Dashboard shows summary cards for income, expenses, and balance
## v3 Requirements
Deferred to future release. Tracked but not in current roadmap.
### Analytics & Insights
- **ANALYTICS-01**: User can see spending trends over months
- **ANALYTICS-02**: User gets income-based spending recommendations by category
### Integrations
- **IMPORT-01**: User can import actuals from CSV/bank export
- **IMPORT-02**: User can import recurring transactions automatically
### Polish
- **POLISH-01**: User can use the app in dark mode
- **POLISH-02**: User sees "Review your template" prompt after 3 months of use
- **POLISH-03**: User sees "Edit template" shortcut link from budget view
## Out of Scope
| Feature | Reason |
|---------|--------|
| AI-suggested budget amounts | Requires spending history that new users don't have; static defaults sufficient |
| Mandatory wizard (no skip) | Anti-feature — patronizing for returning users; YNAB removed forced onboarding |
| 6+ step wizard | Drop-off increases sharply beyond 3 steps |
| Bank account sync (Plaid) | High infrastructure/security cost for self-hosted tool |
| Shared/household budgets | Single-user app; future consideration |
| Mobile-first optimization | Desktop-first; basic responsiveness only |
| Keep Quick Add as separate page alongside inline add | Two surfaces for same action creates confusion |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| DS-01 | Phase 5 | Pending |
| DS-02 | Phase 5 | Pending |
| DS-03 | Phase 5 | Pending |
| SETUP-01 | Phase 6, 7 | Pending |
| SETUP-02 | Phase 6, 7 | Pending |
| SETUP-03 | Phase 7 | Pending |
| SETUP-04 | Phase 7 | Pending |
| SETUP-05 | Phase 7 | Pending |
| AUTO-01 | Phase 6, 8 | Pending |
| AUTO-02 | Phase 8 | Pending |
| AUTO-03 | Phase 6, 8 | Pending |
| BV-01 | Phase 9 | Pending |
| BV-02 | Phase 9 | Pending |
| BV-03 | Phase 9 | Pending |
| DASH-01 | Phase 9 | Pending |
| DASH-02 | Phase 9 | Pending |
| DASH-03 | Phase 9 | Pending |
**Coverage:**
- v2.0 requirements: 17 total
- Mapped to phases: 17
- Unmapped: 0 ✓
---
*Requirements defined: 2026-04-02*
*Last updated: 2026-04-02 after v2.0 roadmap creation*

116
.planning/ROADMAP.md Normal file
View File

@@ -0,0 +1,116 @@
# Roadmap: SimpleFinanceDash v2.0 — UX Simplification & Design Rework
## Overview
This milestone transforms SimpleFinanceDash from a visually polished but cognitively complex app into a guided, intuitive budgeting experience. Work flows from a global design token rework (sharp edges, clearer pastels) through data safety foundations, a wizard-driven first-run setup, automatic budget creation, and finally a simplified budget view with inline item adding. Every phase delivers a coherent, verifiable capability on top of the fully working v1.0 baseline.
## Phases
**Phase Numbering:**
- Integer phases (5-9): v2.0 milestone work, continuing from v1.0's Phase 4
- Decimal phases: Urgent insertions if needed (marked INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 5: Design System Token Rework** - Replace rounded corners with sharp edges and refine OKLCH pastel tokens across all 9 pages
- [ ] **Phase 6: Preset Data, First-Run Detection, and DB Safety** - Seed preset library, build first-run hook, and add DB uniqueness constraints that protect against duplicate data
- [ ] **Phase 7: Setup Wizard** - 3-step first-run wizard with income, pre-filled recurring items, and review — skippable, state-persisted, bilingual
- [ ] **Phase 8: Auto-Budget Creation** - Auto-create current month's budget from template on dashboard visit, with correct currency and first-creation toast
- [ ] **Phase 9: Inline Add and Dashboard Simplification** - Replace Quick Add page with inline Sheet panel, simplify dashboard to at-a-glance view, add empty states
## Phase Details
### Phase 5: Design System Token Rework
**Goal**: Users see a sharp, minimal, clearly pastel UI across every page — the visual foundation that all subsequent phases build on
**Depends on**: Phase 4 (v1.0 complete)
**Requirements**: DS-01, DS-02, DS-03
**Success Criteria** (what must be TRUE):
1. Every rounded element across all 9 pages has sharp corners — no pill buttons, no rounded cards, no rounded inputs visible anywhere in the app
2. Category color swatches are visibly colorful against white backgrounds — not washed-out or grey-tinted — and still pass WCAG 4.5:1 contrast for text
3. Page layouts feel uncluttered — content sections have consistent whitespace gaps and no visual crowding between cards or sections
4. A full visual pass of all 9 pages (Dashboard, Budget List, Budget Detail, Template, Categories, Quick Add, Settings, Login, Register) confirms no regressions from token changes
**Plans**: TBD
### Phase 6: Preset Data, First-Run Detection, and DB Safety
**Goal**: The data layer is safe and ready — duplicate budget/category writes are impossible at the DB level, and the app correctly identifies first-run users
**Depends on**: Phase 5
**Requirements**: AUTO-01, AUTO-03, SETUP-01, SETUP-02
**Success Criteria** (what must be TRUE):
1. Attempting to create two budgets for the same user and month is rejected at the DB level (unique constraint on `budgets(user_id, start_date)`)
2. Attempting to create two categories with the same name for the same user is rejected at the DB level (unique constraint on `categories(user_id, name)`)
3. All existing v1.0 users have `profiles.setup_completed = true` after the backfill migration runs — they will not be shown the wizard on their next login
4. `useFirstRunState` hook returns `true` only for users with zero categories or zero template items — returning `false` for any user with existing data
5. `src/data/presets.ts` contains ~15-20 curated budget items with sensible default amounts, grouped by category type, with both English and German i18n translation keys defined
**Plans**: TBD
### Phase 7: Setup Wizard
**Goal**: A new user can set up their budget template in under 3 minutes by following a guided 3-step wizard with pre-filled common items and a live running balance
**Depends on**: Phase 6
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04, SETUP-05
**Success Criteria** (what must be TRUE):
1. A new user (zero categories, zero template items) is automatically redirected to `/setup` on their first login and sees a 3-step wizard: income entry, recurring items selection, and review
2. The recurring items step shows ~15-20 pre-filled common items (rent, groceries, car insurance, utilities, etc.) with editable default amounts — user can check or uncheck each
3. A "remaining to allocate" balance updates live as items are checked or unchecked in step 2, showing income minus the sum of selected item amounts
4. User can click "Skip" on any individual step or "Skip setup" to exit the wizard entirely without creating any data — and lands on the dashboard
5. Completing the wizard creates a populated template from selected items — user lands on the dashboard with their template in place, and refreshing the browser mid-wizard restores the wizard at the correct step
**Plans**: TBD
### Phase 8: Auto-Budget Creation
**Goal**: Users never manually trigger budget creation — visiting a month for the first time automatically creates their budget from the template, silently and correctly
**Depends on**: Phase 7
**Requirements**: AUTO-01, AUTO-02, AUTO-03
**Success Criteria** (what must be TRUE):
1. Navigating to the dashboard for a month that has no budget (but a populated template) creates the budget automatically — no button click or prompt required
2. The first time a budget is auto-created in a session, a toast notification appears naming the month and indicating it was created from the template — subsequent months are created silently
3. The auto-created budget uses the user's configured currency from their profile settings, not a hardcoded default
4. Rapidly navigating between months or opening the dashboard in two tabs does not produce duplicate budget rows
**Plans**: TBD
### Phase 9: Inline Add and Dashboard Simplification
**Goal**: Users add one-off items to their budget directly from the budget view, the Quick Add page is gone, and the dashboard shows this month's data at a glance without chart overload
**Depends on**: Phase 8
**Requirements**: BV-01, BV-02, BV-03, DASH-01, DASH-02, DASH-03
**Success Criteria** (what must be TRUE):
1. An "Add item" control on the budget detail page opens a Sheet panel showing the category library — user selects a category, enters an amount, and the item appears in the budget without a page navigation
2. The Quick Add page is removed from the sidebar navigation — visiting `/quick-add` redirects to `/budgets` instead of showing an error
3. The dashboard displays this month's summary cards (income, expenses, balance) with correct totals that match what the user entered in the budget detail page
4. The dashboard does not show the 3-column chart grid by default — the primary view is the at-a-glance summary and collapsible category sections
5. Empty template, empty dashboard, and empty budget view each show a clear empty state with a visible call-to-action guiding the user to the next step
**Plans**: TBD
## Requirements Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| DS-01 | Phase 5 | Pending |
| DS-02 | Phase 5 | Pending |
| DS-03 | Phase 5 | Pending |
| SETUP-01 | Phase 6, 7 | Pending |
| SETUP-02 | Phase 6, 7 | Pending |
| SETUP-03 | Phase 7 | Pending |
| SETUP-04 | Phase 7 | Pending |
| SETUP-05 | Phase 7 | Pending |
| AUTO-01 | Phase 6, 8 | Pending |
| AUTO-02 | Phase 8 | Pending |
| AUTO-03 | Phase 6, 8 | Pending |
| BV-01 | Phase 9 | Pending |
| BV-02 | Phase 9 | Pending |
| BV-03 | Phase 9 | Pending |
| DASH-01 | Phase 9 | Pending |
| DASH-02 | Phase 9 | Pending |
| DASH-03 | Phase 9 | Pending |
**Coverage: 17/17 v2.0 requirements mapped. No orphans.**
## Progress
**Execution Order:**
Phases execute in numeric order: 5 → 6 → 7 → 8 → 9
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 5. Design System Token Rework | 0/? | Not started | - |
| 6. Preset Data, First-Run Detection, DB Safety | 0/? | Not started | - |
| 7. Setup Wizard | 0/? | Not started | - |
| 8. Auto-Budget Creation | 0/? | Not started | - |
| 9. Inline Add and Dashboard Simplification | 0/? | Not started | - |

View File

@@ -1,67 +1,58 @@
--- ---
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v2.0
milestone_name: milestone milestone_name: UX Simplification & Design Rework
status: planning status: ready_to_plan
stopped_at: Completed 04-03-PLAN.md stopped_at: null
last_updated: "2026-03-24T08:42:34.008Z" last_updated: "2026-04-02T00:00:00.000Z"
last_activity: 2026-03-24 last_activity: 2026-04-02
progress: progress:
total_phases: 4 total_phases: 5
completed_phases: 4 completed_phases: 0
total_plans: 10 total_plans: 0
completed_plans: 10 completed_plans: 0
percent: 100 percent: 0
--- ---
# Project State # Project State
## Project Reference ## Project Reference
See: .planning/PROJECT.md (updated 2026-03-16) See: .planning/PROJECT.md (updated 2026-04-02)
**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. **Core value:** 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 focus:** Phase 3Collapsible Dashboard Sections **Current focus:** Phase 5Design System Token Rework
## Current Position ## Current Position
Phase: 04 of 4 (full app design consistency) Phase: 5 of 9 overall (1 of 5 in v2.0)
Plan: Not started Plan: Not started
Status: Ready to plan Status: Ready to plan
Last activity: 2026-03-24 Last activity: 2026-04-02 — v2.0 roadmap created, 5 phases defined
Progress: [████████████████████] 5/5 plans (100%) Progress: [░░░░░░░░░░░░░░░░░░░░] 0/? plans (0%)
## Performance Metrics ## Performance Metrics
**Velocity:** **Velocity (v1.0 baseline):**
- Total plans completed (v1.0): 10
- Average duration: ~2.4 min/plan
- Total execution time: ~24 min
- Total plans completed: 0 **By Phase (v1.0):**
- Average duration: -
- Total execution time: 0 hours
**By Phase:**
| Phase | Plans | Total | Avg/Plan | | Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------| |-------|-------|-------|----------|
| - | - | - | - | | 01 Design Foundation | 2 | ~5 min | 2.5 min |
| 02 Dashboard Charts | 3 | ~7 min | 2.3 min |
**Recent Trend:** | 03 Collapsible Sections | 2 | ~4 min | 2 min |
| 04 Full-App Consistency | 3 | ~7 min | 2.3 min |
**v2.0 Trend:**
- Last 5 plans: - - Last 5 plans: -
- Trend: - - Trend: -
*Updated after each plan completion* *Updated after each plan completion*
| Phase 01 P01 | 3min | 2 tasks | 5 files |
| Phase 01 P02 | 2min | 2 tasks | 5 files |
| Phase 02 P02 | 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 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
@@ -70,30 +61,9 @@ Progress: [████████████████████] 5/5 pla
Decisions are logged in PROJECT.md Key Decisions table. Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work: Recent decisions affecting current work:
- [Roadmap]: 4-phase structure derived from research — foundation before features, dashboard before other pages, charts before collapsibles - [Roadmap v2.0]: 5-phase structure — tokens first (global scope), DB safety before UI writes, wizard before auto-budget, auto-budget before inline add
- [Roadmap]: All research flags set to NO — no `/gsd:research-phase` needed before any phase - [Roadmap v2.0]: Phase 6 kept separate from Phase 7 — DB constraints and backfill migration must land before any wizard UI to prevent data corruption
- [Phase 01]: Applied Recharts v3 initialDimension patch on chart.tsx (shadcn-ui/ui#9892) - [Roadmap v2.0]: SETUP-01, SETUP-02, AUTO-01, AUTO-03 split across Phase 6 and Phase 7/8 — Phase 6 provides the prerequisite DB/data layer, later phases deliver user-visible behavior
- [Phase 01]: Category text colors darkened to oklch ~0.55 for WCAG 4.5:1 contrast; chart fills kept lighter at ~0.65-0.70 (two-tier pattern)
- [Phase 01]: StatCard uses font-bold (upgraded from font-semibold) for stronger KPI visual weight
- [Phase 01]: SummaryStrip accepts t() as prop to stay presentational; DashboardSkeleton mirrors exact grid layout to prevent shift
- [Phase 02]: Donut legend placed below chart for better 3-column layout fit
- [Phase 02]: ChartEmptyState created in Plan 02 as Rule 3 deviation (blocking dep from Plan 01)
- [Phase 02]: MonthNavigator uses Select dropdown for month jump -- consistent with existing form patterns
- [Phase 02]: useMonthParam uses useSearchParams callback form to preserve other URL params
- [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]: 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
@@ -101,10 +71,11 @@ None yet.
### Blockers/Concerns ### Blockers/Concerns
- ⚠️ [Phase 02-03] 6 pre-existing lint errors in unrelated files (MonthNavigator.tsx, badge.tsx, button.tsx, sidebar.tsx, useBudgets.ts) — not caused by Phase 2 changes but may affect Phase 3 CI - ⚠️ [Phase 8] Verify `profiles` table currency column name before writing auto-create code — check `supabase/migrations/001_profiles.sql` at planning time (research flagged this)
- ⚠️ [Phase 6] `setup_completed` backfill migration must ship before Phase 7 deploys — existing v1.0 users must not see the wizard on first v2.0 login
## Session Continuity ## Session Continuity
Last session: 2026-03-17T15:23:32.629Z Last session: 2026-04-02
Stopped at: Completed 04-03-PLAN.md Stopped at: Roadmap created — ready to plan Phase 5
Resume file: None Resume file: None

View File

@@ -3,11 +3,12 @@
"granularity": "coarse", "granularity": "coarse",
"parallelization": true, "parallelization": true,
"commit_docs": true, "commit_docs": true,
"model_profile": "quality", "model_profile": "balanced",
"workflow": { "workflow": {
"research": true, "research": true,
"plan_check": true, "plan_check": true,
"verifier": true, "verifier": true,
"nyquist_validation": true "nyquist_validation": true,
"_auto_chain_active": true
} }
} }

View File

@@ -2,22 +2,22 @@
phase: 02-dashboard-charts-and-layout phase: 02-dashboard-charts-and-layout
plan: 01 plan: 01
subsystem: ui subsystem: ui
tags: [react, react-router, i18n, dashboard, charts] tags: [react, react-router, useSearchParams, select, lucide-react, i18n, dashboard, charts]
# Dependency graph # Dependency graph
requires: requires:
- phase: 01-design-foundation-and-primitives - phase: 01-design-foundation-and-primitives
provides: PageShell, design tokens, shadcn chart primitive provides: "Design tokens, PageShell, SummaryStrip, ChartEmptyState component"
provides: provides:
- useMonthParam hook for URL-based month navigation - "useMonthParam hook for URL-based month state"
- MonthNavigator component with prev/next arrows and Select dropdown - "MonthNavigator component with prev/next arrows and month dropdown"
- Chart and month navigation i18n keys (EN + DE) - "i18n keys for chart labels and month navigation (EN + DE)"
affects: [02-dashboard-charts-and-layout, 03-collapsible-dashboard-sections] affects: [02-02, 02-03, 03-collapsible-dashboard-sections, dashboard-page-refactor]
# Tech tracking # Tech tracking
tech-stack: tech-stack:
added: [] added: []
patterns: [URL-based state via useSearchParams, locale-aware month formatting] patterns: [URL search param state via useMonthParam, locale-aware month formatting]
key-files: key-files:
created: created:
@@ -28,13 +28,14 @@ key-files:
- src/i18n/de.json - src/i18n/de.json
key-decisions: key-decisions:
- "MonthNavigator uses Select dropdown (not popover) for month jump -- consistent with existing form patterns"
- "useMonthParam preserves other URL params via setSearchParams callback form" - "useMonthParam preserves other URL params via setSearchParams callback form"
- "navigateMonth uses Date constructor for automatic year rollover" - "navigateMonth uses Date constructor for automatic year rollover"
- "MonthNavigator accepts t prop but dropdown uses locale-aware Intl formatting" - "MonthNavigator accepts t prop but dropdown uses locale-aware Intl formatting"
- "ChartEmptyState already existed from Phase 1 — skipped creation, added i18n keys only" - "ChartEmptyState already existed from Phase 1 — skipped creation, added i18n keys only"
patterns-established: patterns-established:
- "URL-based month state: useMonthParam hook as single source of truth for month selection" - "useMonthParam: URL state hook pattern using useSearchParams callback form to preserve other params"
- "Month formatting: Date.toLocaleDateString with month:'long', year:'numeric'" - "Month formatting: Date.toLocaleDateString with month:'long', year:'numeric'"
requirements-completed: [UI-DASH-01] requirements-completed: [UI-DASH-01]
@@ -51,15 +52,15 @@ completed: 2026-03-16
## Performance ## Performance
- **Duration:** ~2 min - **Duration:** ~2 min
- **Started:** 2026-03-16T12:02:00Z - **Started:** 2026-03-16T12:01:18Z
- **Completed:** 2026-03-16T12:03:06Z - **Completed:** 2026-03-16T12:03:21Z
- **Tasks:** 2 - **Tasks:** 2
- **Files modified:** 4 - **Files modified:** 4
## Accomplishments ## Accomplishments
- useMonthParam hook reads/writes `?month=YYYY-MM` URL param with current-month fallback and year-rollover navigation - useMonthParam hook reads/writes `?month=YYYY-MM` from URL with current month fallback and year-rollover-safe navigation
- MonthNavigator renders prev/next chevron buttons and a Select dropdown of available budget months with locale-aware formatting - MonthNavigator renders prev/next chevron buttons and a Select dropdown listing 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 - Added 10 new i18n keys under `dashboard.*` for chart labels, month navigation, and empty states in both English and German
## Task Commits ## Task Commits
@@ -69,15 +70,15 @@ Each task was committed atomically:
2. **Task 2: Add chart and month navigation i18n keys** - `42bf1f9` (feat) 2. **Task 2: Add chart and month navigation i18n keys** - `42bf1f9` (feat)
## Files Created/Modified ## Files Created/Modified
- `src/hooks/useMonthParam.ts` - URL-based month state hook with navigateMonth for year rollover - `src/hooks/useMonthParam.ts` - Custom hook wrapping useSearchParams for month URL state with setMonth and navigateMonth
- `src/components/dashboard/MonthNavigator.tsx` - Prev/next arrows + Select dropdown for month selection - `src/components/dashboard/MonthNavigator.tsx` - Prev/next arrow buttons + Select dropdown for month selection
- `src/i18n/en.json` - 10 new dashboard chart and navigation keys - `src/i18n/en.json` - Added monthNav, noData, expenseDonut, incomeChart, spendChart, budgeted, actual, noBudgetForMonth, createBudget, generateFromTemplate keys
- `src/i18n/de.json` - Matching German translations - `src/i18n/de.json` - German translations for all 10 new dashboard keys
## Decisions Made ## Decisions Made
- ChartEmptyState component already existed from Phase 1 — only i18n keys were added, component creation skipped - MonthNavigator uses the shadcn Select component (not a popover or custom dropdown) for month selection -- consistent with existing form patterns in the project
- useMonthParam uses setSearchParams callback form to preserve other URL params - formatMonthLabel uses `Date.toLocaleDateString(undefined, { month: "long", year: "numeric" })` for locale-aware month display rather than manual string formatting
- MonthNavigator uses Date.toLocaleDateString for locale-aware month display - ChartEmptyState component was already created during Phase 1 scaffolding -- verified it matches the plan specification exactly, so no changes were needed
## Deviations from Plan ## Deviations from Plan
@@ -102,9 +103,18 @@ None
None - no external service configuration required. None - no external service configuration required.
## Next Phase Readiness ## Next Phase Readiness
- Month navigation infrastructure ready for dashboard integration (Plan 03) - useMonthParam hook ready for consumption by DashboardPage refactor (Plan 03)
- Chart components (Plan 02) can reference i18n keys - MonthNavigator ready to be placed in PageShell action slot (Plan 03)
- All foundational pieces in place for DashboardPage wiring - ChartEmptyState ready for use in chart components (Plans 02)
- All chart i18n keys available for chart components (Plan 02)
## Self-Check: PASSED
- FOUND: src/hooks/useMonthParam.ts
- FOUND: src/components/dashboard/MonthNavigator.tsx
- FOUND: src/components/dashboard/charts/ChartEmptyState.tsx
- FOUND: commit 4481950
- FOUND: commit 42bf1f9
--- ---
*Phase: 02-dashboard-charts-and-layout* *Phase: 02-dashboard-charts-and-layout*

View File

@@ -119,6 +119,10 @@ None - no external service configuration required.
- `CollapsibleSections` expects: `groups[]`, `currency`, `openSections: Record<string,boolean>`, `onToggleSection`, `t` - `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` - Plan 02 needs to: group `items` by `CategoryType`, compute per-group totals, manage `openSections` state in `DashboardContent`
## Self-Check: PASSED
All 9 files verified present. Both task commits (21ce6d8, f30b846) and metadata commit (7d08da2) confirmed in git log.
--- ---
*Phase: 03-collapsible-dashboard-sections* *Phase: 03-collapsible-dashboard-sections*
*Completed: 2026-03-17* *Completed: 2026-03-17*

View File

@@ -0,0 +1,105 @@
# Phase 3: Collapsible Dashboard Sections - Context
**Gathered:** 2026-03-16
**Status:** Ready for planning
<domain>
## Phase Boundary
Complete the dashboard hybrid view with collapsible per-category sections that show individual line items, group totals, and variance indicators. Add carryover amount display to the balance card. Sections sit below the chart grid and above QuickAdd. This phase does not add editing capabilities or navigation links — the dashboard remains a read-only summary.
</domain>
<decisions>
## Implementation 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
</decisions>
<specifics>
## Specific Ideas
No specific references — open to standard approaches within the established design system.
</specifics>
<code_context>
## Existing Code Insights
### Reusable Assets
- `collapsible.tsx` (ui/): Radix Collapsible primitive already installed — `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` exports ready to use
- `categoryColors` (palette.ts): CSS variable map for all 6 category types — use for left border accent colors
- `categoryLabels` (palette.ts): English/German labels for all 6 types — use for section header labels
- `Table`/`TableBody`/`TableCell`/`TableFooter`/`TableHead`/`TableHeader`/`TableRow` (ui/table.tsx): Full table primitive set for line items
- `Badge` (ui/badge.tsx): Use for budget/actual chips in section headers
- `StatCard` (components/dashboard/StatCard.tsx): Balance card that needs carryover subtitle
- `SummaryStrip` (components/dashboard/SummaryStrip.tsx): Orchestrates StatCards — may need carryover prop threading
- `formatCurrency` (lib/format.ts): Currency formatting for all monetary values
- `ChevronRight`/`ChevronDown` from `lucide-react`: Expand/collapse icons
### Established Patterns
- Two-tier OKLCH color pattern: text at ~0.55 lightness, chart fills at ~0.65-0.70 (Phase 1) — section border accents should use fill-tier lightness
- Semantic status tokens: `--color-over-budget` (red) and `--color-on-budget` (green) for difference indicators (Phase 1)
- Components accept `t()` as prop to stay presentational (Phase 1) — new section components should follow this
- `useMemo` hooks declared before early returns for Rules of Hooks compliance (Phase 2)
- `EXPENSE_TYPES` / `CATEGORY_TYPES` constants already define category ordering — reuse for section order
- `DashboardContent` pattern: inner component receives budget data, derives per-type totals (Phase 2)
### Integration Points
- `DashboardContent` (DashboardPage.tsx): Currently renders SummaryStrip → chart grid → QuickAdd. Collapsible sections insert between chart grid and QuickAdd.
- `useBudgetDetail(id)`: Returns `{ budget, items }` where items have category joins — items need grouping by `category.type` for sections
- `Budget.carryover_amount`: Field already exists on the Budget type — needs to flow from DashboardContent → SummaryStrip → StatCard
- `useMonthParam`: Month navigation resets expand/collapse state — state should be local React state (useState), not URL params
- i18n: New keys needed for section headers, table column labels, carryover subtitle, and empty states
</code_context>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 03-collapsible-dashboard-sections*
*Context gathered: 2026-03-16*

View File

@@ -0,0 +1,116 @@
# Roadmap: SimpleFinanceDash v2.0 — UX Simplification & Design Rework
## Overview
This milestone transforms SimpleFinanceDash from a visually polished but cognitively complex app into a guided, intuitive budgeting experience. Work flows from a global design token rework (sharp edges, clearer pastels) through data safety foundations, a wizard-driven first-run setup, automatic budget creation, and finally a simplified budget view with inline item adding. Every phase delivers a coherent, verifiable capability on top of the fully working v1.0 baseline.
## Phases
**Phase Numbering:**
- Integer phases (5-9): v2.0 milestone work, continuing from v1.0's Phase 4
- Decimal phases: Urgent insertions if needed (marked INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [ ] **Phase 5: Design System Token Rework** - Replace rounded corners with sharp edges and refine OKLCH pastel tokens across all 9 pages
- [ ] **Phase 6: Preset Data, First-Run Detection, and DB Safety** - Seed preset library, build first-run hook, and add DB uniqueness constraints that protect against duplicate data
- [ ] **Phase 7: Setup Wizard** - 3-step first-run wizard with income, pre-filled recurring items, and review — skippable, state-persisted, bilingual
- [ ] **Phase 8: Auto-Budget Creation** - Auto-create current month's budget from template on dashboard visit, with correct currency and first-creation toast
- [ ] **Phase 9: Inline Add and Dashboard Simplification** - Replace Quick Add page with inline Sheet panel, simplify dashboard to at-a-glance view, add empty states
## Phase Details
### Phase 5: Design System Token Rework
**Goal**: Users see a sharp, minimal, clearly pastel UI across every page — the visual foundation that all subsequent phases build on
**Depends on**: Phase 4 (v1.0 complete)
**Requirements**: DS-01, DS-02, DS-03
**Success Criteria** (what must be TRUE):
1. Every rounded element across all 9 pages has sharp corners — no pill buttons, no rounded cards, no rounded inputs visible anywhere in the app
2. Category color swatches are visibly colorful against white backgrounds — not washed-out or grey-tinted — and still pass WCAG 4.5:1 contrast for text
3. Page layouts feel uncluttered — content sections have consistent whitespace gaps and no visual crowding between cards or sections
4. A full visual pass of all 9 pages (Dashboard, Budget List, Budget Detail, Template, Categories, Quick Add, Settings, Login, Register) confirms no regressions from token changes
**Plans**: TBD
### Phase 6: Preset Data, First-Run Detection, and DB Safety
**Goal**: The data layer is safe and ready — duplicate budget/category writes are impossible at the DB level, and the app correctly identifies first-run users
**Depends on**: Phase 5
**Requirements**: AUTO-01, AUTO-03, SETUP-01, SETUP-02
**Success Criteria** (what must be TRUE):
1. Attempting to create two budgets for the same user and month is rejected at the DB level (unique constraint on `budgets(user_id, start_date)`)
2. Attempting to create two categories with the same name for the same user is rejected at the DB level (unique constraint on `categories(user_id, name)`)
3. All existing v1.0 users have `profiles.setup_completed = true` after the backfill migration runs — they will not be shown the wizard on their next login
4. `useFirstRunState` hook returns `true` only for users with zero categories or zero template items — returning `false` for any user with existing data
5. `src/data/presets.ts` contains ~15-20 curated budget items with sensible default amounts, grouped by category type, with both English and German i18n translation keys defined
**Plans**: TBD
### Phase 7: Setup Wizard
**Goal**: A new user can set up their budget template in under 3 minutes by following a guided 3-step wizard with pre-filled common items and a live running balance
**Depends on**: Phase 6
**Requirements**: SETUP-01, SETUP-02, SETUP-03, SETUP-04, SETUP-05
**Success Criteria** (what must be TRUE):
1. A new user (zero categories, zero template items) is automatically redirected to `/setup` on their first login and sees a 3-step wizard: income entry, recurring items selection, and review
2. The recurring items step shows ~15-20 pre-filled common items (rent, groceries, car insurance, utilities, etc.) with editable default amounts — user can check or uncheck each
3. A "remaining to allocate" balance updates live as items are checked or unchecked in step 2, showing income minus the sum of selected item amounts
4. User can click "Skip" on any individual step or "Skip setup" to exit the wizard entirely without creating any data — and lands on the dashboard
5. Completing the wizard creates a populated template from selected items — user lands on the dashboard with their template in place, and refreshing the browser mid-wizard restores the wizard at the correct step
**Plans**: TBD
### Phase 8: Auto-Budget Creation
**Goal**: Users never manually trigger budget creation — visiting a month for the first time automatically creates their budget from the template, silently and correctly
**Depends on**: Phase 7
**Requirements**: AUTO-01, AUTO-02, AUTO-03
**Success Criteria** (what must be TRUE):
1. Navigating to the dashboard for a month that has no budget (but a populated template) creates the budget automatically — no button click or prompt required
2. The first time a budget is auto-created in a session, a toast notification appears naming the month and indicating it was created from the template — subsequent months are created silently
3. The auto-created budget uses the user's configured currency from their profile settings, not a hardcoded default
4. Rapidly navigating between months or opening the dashboard in two tabs does not produce duplicate budget rows
**Plans**: TBD
### Phase 9: Inline Add and Dashboard Simplification
**Goal**: Users add one-off items to their budget directly from the budget view, the Quick Add page is gone, and the dashboard shows this month's data at a glance without chart overload
**Depends on**: Phase 8
**Requirements**: BV-01, BV-02, BV-03, DASH-01, DASH-02, DASH-03
**Success Criteria** (what must be TRUE):
1. An "Add item" control on the budget detail page opens a Sheet panel showing the category library — user selects a category, enters an amount, and the item appears in the budget without a page navigation
2. The Quick Add page is removed from the sidebar navigation — visiting `/quick-add` redirects to `/budgets` instead of showing an error
3. The dashboard displays this month's summary cards (income, expenses, balance) with correct totals that match what the user entered in the budget detail page
4. The dashboard does not show the 3-column chart grid by default — the primary view is the at-a-glance summary and collapsible category sections
5. Empty template, empty dashboard, and empty budget view each show a clear empty state with a visible call-to-action guiding the user to the next step
**Plans**: TBD
## Requirements Traceability
| Requirement | Phase | Status |
|-------------|-------|--------|
| DS-01 | Phase 5 | Pending |
| DS-02 | Phase 5 | Pending |
| DS-03 | Phase 5 | Pending |
| SETUP-01 | Phase 6, 7 | Pending |
| SETUP-02 | Phase 6, 7 | Pending |
| SETUP-03 | Phase 7 | Pending |
| SETUP-04 | Phase 7 | Pending |
| SETUP-05 | Phase 7 | Pending |
| AUTO-01 | Phase 6, 8 | Pending |
| AUTO-02 | Phase 8 | Pending |
| AUTO-03 | Phase 6, 8 | Pending |
| BV-01 | Phase 9 | Pending |
| BV-02 | Phase 9 | Pending |
| BV-03 | Phase 9 | Pending |
| DASH-01 | Phase 9 | Pending |
| DASH-02 | Phase 9 | Pending |
| DASH-03 | Phase 9 | Pending |
**Coverage: 17/17 v2.0 requirements mapped. No orphans.**
## Progress
**Execution Order:**
Phases execute in numeric order: 5 → 6 → 7 → 8 → 9
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 5. Design System Token Rework | 0/? | Not started | - |
| 6. Preset Data, First-Run Detection, DB Safety | 0/? | Not started | - |
| 7. Setup Wizard | 0/? | Not started | - |
| 8. Auto-Budget Creation | 0/? | Not started | - |
| 9. Inline Add and Dashboard Simplification | 0/? | Not started | - |

View File

@@ -1,452 +1,545 @@
# Architecture Research # Architecture Research
**Domain:** Personal finance dashboard UI — React SPA overhaul **Domain:** Personal budget dashboard — v2.0 UX simplification with wizard setup, auto-budget creation, inline add-from-library, design system rework
**Researched:** 2026-03-16 **Researched:** 2026-04-02
**Confidence:** HIGH (existing codebase is fully inspected; patterns are grounded in Radix/shadcn/Recharts official docs) **Confidence:** HIGH (based on full codebase inspection; all claims verified against source files)
--- ---
## Standard Architecture ## Existing Architecture Baseline
### System Overview This is a subsequent milestone. The section below describes what EXISTS today, then each sub-section documents exactly what changes and what stays the same.
The existing three-tier architecture (Pages → Hooks → Supabase) is sound and must be preserved. The UI overhaul introduces a new layer of **dashboard-specific view components** that sit between pages and the primitive shadcn/ui atoms. Nothing touches hooks or the library layer. ### Current System Layout
``` ```
┌───────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────
Pages Layer React 19 SPA (Vite + TypeScript)
DashboardPage CategoriesPage BudgetDetailPage ...
(routing, data loading, layout composition) Auth Layer App Layer (ProtectedRoute + AppLayout)
───────────────────────────────────────────────────────────────┤ │ ┌───────────────┐ ┌─────────────────────────────────────────┐ │
View Components Layer [NEW] │ LoginPage │ SidebarProvider > Sidebar > SidebarInset│
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ │ RegisterPage │ │ 6 nav items → 6 protected page routes │
│ DashboardContent│ │CategorySection│ │ ChartPanel │ └───────────────┘ └─────────────────────────────────────────┘
│ (hybrid layout) │ │(collapsible) │ │ (chart wrappers)│
└─────────────────┘ └──────────────┘ └─────────────────┘ Pages (each uses PageShell + direct hook calls):
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ DashboardPage CategoriesPage TemplatePage BudgetListPage
│ SummaryStrip │ BudgetTable PageShell BudgetDetailPage QuickAddPage SettingsPage
│ (KPI cards row) │ │ (line items) │ │ (consistent
└─────────────────┘ └──────────────┘ │ header+CTA) │ Hooks (TanStack Query v5, direct supabase-js client):
└─────────────────┘ useAuth useCategories useTemplate useBudgets useQuickAdd
├───────────────────────────────────────────────────────────────┤ │ useMonthParam useBudgetDetail │
Primitive UI Layer (shadcn/ui)
Card Button Table Dialog Select Collapsible Badge ... Design layer:
├───────────────────────────────────────────────────────────────┤ │ index.css @theme inline → OKLCH tokens → Tailwind 4 utility classes│
Hooks Layer [UNCHANGED] lib/palette.ts → categoryColors (CSS var references only)
useBudgets useBudgetDetail useCategories useAuth ... shadcn/ui (radix-ui primitives + generated component files)
───────────────────────────────────────────────────────────────┤ └─────────────────────────┬───────────────────────────────────────────
Library Layer [UNCHANGED] │ │ @supabase/supabase-js v2 (REST + RLS)
│ supabase.ts types.ts format.ts palette.ts utils.ts │ ┌─────────────────────────▼───────────────────────────────────────────┐
index.css (@theme tokens — EXTEND for new color tokens) Supabase (PostgreSQL 16 + Auth + Row Level Security)
└───────────────────────────────────────────────────────────────┘ │ Tables: profiles, categories, templates, template_items, │
│ budgets, budget_items, quick_add_items │
└─────────────────────────────────────────────────────────────────────┘
``` ```
**The constraint is strict:** hooks and library are read-only during this milestone. All UI overhaul changes land in `src/pages/`, `src/components/`, and `src/index.css` only. ### Key Existing Patterns (Carry Forward Unchanged)
| Pattern | Location | Keep? |
|---------|----------|-------|
| PageShell header wrapper | `components/shared/PageShell.tsx` | YES |
| DashboardContent inner component keyed by budgetId | `DashboardPage.tsx` | YES — key prop pattern prevents stale state on month change |
| useMonthParam URL search param | `hooks/useMonthParam.ts` | YES |
| getOrCreateTemplate (auto-creates template row) | `hooks/useTemplate.ts` | YES |
| generateFromTemplate mutation | `hooks/useBudgets.ts` | YES — v2.0 triggers it automatically |
| Two-tier OKLCH color tokens (text ~0.55L, fill ~0.68L) | `index.css` | YES — values tuned, names unchanged |
| categoryColors CSS var references | `lib/palette.ts` | YES — names unchanged |
| Direction-aware diff (income under = bad, spending over = bad) | `BudgetDetailPage.tsx`, `CategorySection.tsx` | YES |
| TanStack Query cache invalidation per resource key | All mutation hooks | YES |
--- ---
### Component Responsibilities ## v2.0 Architecture Changes
| Component | Responsibility | Typical Implementation | ### 1. First-Run Detection and Wizard Routing
|-----------|----------------|------------------------|
| `DashboardPage` | Find current month budget, render shell | Unchanged outer page; delegates to `DashboardContent` |
| `DashboardContent` | Hybrid layout orchestration | Calls `useBudgetDetail`; computes derived data with `useMemo`; renders SummaryStrip + charts + CategorySections |
| `SummaryStrip` | Three KPI cards (income, expenses, balance) | Grid of `StatCard` components; color-coded balance |
| `StatCard` | Single KPI display unit | shadcn Card with title, large number, optional trend indicator |
| `ChartPanel` | Houses all charts in responsive grid | Two-column grid on desktop: income bar chart left, expense donut right, spend horizontal bar full-width below |
| `IncomeBarChart` | Budgeted vs actual income vertical bar | Recharts `BarChart` wrapped in `ChartContainer` with `ChartConfig` |
| `ExpenseDonutChart` | Expense category breakdown donut | Recharts `PieChart` with `innerRadius`/`outerRadius` + custom legend |
| `SpendBarChart` | Horizontal budget vs actual by category type | Recharts `BarChart layout="vertical"` |
| `CategorySection` | Collapsible group for one category type | Radix `Collapsible.Root` wrapping a header row + `BudgetLineItems` |
| `CategorySectionHeader` | Always-visible row: type label, color dot, group totals, chevron | Trigger for the collapsible; shows budgeted/actual/diff inline |
| `BudgetLineItems` | Table of individual line items inside a section | shadcn `Table`; thin wrapper around existing `InlineEditCell` / `DifferenceCell` atoms |
| `PageShell` | Consistent page header with title + primary CTA | Reusable wrapper used by every page; enforces padding, heading size, CTA slot |
| `AppLayout` | Sidebar navigation shell | Minor visual refresh only; structure unchanged |
--- **Problem:** New users land on Dashboard with empty categories, no template, no budget. The blank state offers no guidance.
## Recommended Project Structure **Solution:** A `useFirstRunState` hook detects first-run status. A `WizardRoute` guard in `App.tsx` redirects first-run users to `/setup` (a dedicated wizard page) before they can reach any other protected route.
The existing structure is well-organized. The overhaul adds a `dashboard/` subfolder and a `shared/` subfolder under components — no reorganization of hooks or lib. **New hook — `src/hooks/useFirstRunState.ts`:**
```
src/
├── components/
│ ├── ui/ # shadcn primitives (do not modify)
│ │ └── collapsible.tsx # ADD — Radix Collapsible primitive
│ ├── dashboard/ # ADD — dashboard-specific view components
│ │ ├── DashboardContent.tsx # hybrid layout orchestrator
│ │ ├── SummaryStrip.tsx # KPI cards row
│ │ ├── StatCard.tsx # single KPI card
│ │ ├── ChartPanel.tsx # chart grid container
│ │ ├── IncomeBarChart.tsx # budgeted vs actual income bar
│ │ ├── ExpenseDonutChart.tsx # donut + legend
│ │ ├── SpendBarChart.tsx # horizontal budget vs actual
│ │ ├── CategorySection.tsx # collapsible category group
│ │ └── BudgetLineItems.tsx # line-item table inside section
│ ├── shared/ # ADD — cross-page reusable components
│ │ └── PageShell.tsx # consistent page header + CTA slot
│ ├── AppLayout.tsx # MODIFY — visual refresh only
│ └── QuickAddPicker.tsx # unchanged
├── pages/ # MODIFY — swap DashboardContent import; apply PageShell
│ ├── DashboardPage.tsx
│ ├── BudgetDetailPage.tsx
│ ├── BudgetListPage.tsx
│ ├── CategoriesPage.tsx
│ ├── TemplatePage.tsx
│ ├── QuickAddPage.tsx
│ ├── SettingsPage.tsx
│ ├── LoginPage.tsx
│ └── RegisterPage.tsx
├── hooks/ # UNCHANGED
├── lib/
│ ├── palette.ts # UNCHANGED — CSS vars already defined
│ └── ... # everything else unchanged
├── i18n/
│ ├── en.json # ADD new translation keys
│ └── de.json # ADD new translation keys
└── index.css # ADD semantic color tokens if needed
```
### Structure Rationale
- **`components/dashboard/`:** All dashboard-specific view components are co-located. They have no meaning outside the dashboard, so they do not belong in `shared/`. Avoids polluting the top-level components directory.
- **`components/shared/`:** `PageShell` is the one genuinely cross-page component introduced by this milestone. Keeping it separate signals that it is intentionally reusable, not page-specific.
- **`components/ui/collapsible.tsx`:** The Radix Collapsible primitive is not yet in the project (inspected file list confirms absence). It must be added via `npx shadcn@latest add collapsible` before building `CategorySection`.
---
## Architectural Patterns
### Pattern 1: Derived Data via `useMemo` in DashboardContent
**What:** All computed values — category group totals, chart data arrays, KPI numbers — are derived in one place (`DashboardContent`) using `useMemo`, then passed as plain props to presentational child components. Child components never call hooks or perform calculations themselves.
**When to use:** Any time a value depends on `items` array from `useBudgetDetail`. Centralizing derivation means one cache invalidation (after a budget item update) triggers one recalculation, and all children rerender from the same consistent snapshot.
**Trade-offs:** Slightly more props-passing verbosity. Benefit: children are trivially testable pure components.
**Example:**
```typescript ```typescript
// DashboardContent.tsx // Returns { needsSetup: boolean, loading: boolean }
const { budget, items } = useBudgetDetail(budgetId) // needsSetup = true when: categories.length === 0 OR template_items.length === 0
// Uses existing useCategories() and useTemplate() — no new DB queries
const totals = useMemo(() => { export function useFirstRunState(): { needsSetup: boolean; loading: boolean }
const income = items
.filter(i => i.category?.type === "income")
.reduce((sum, i) => sum + i.actual_amount, 0)
const expenses = items
.filter(i => i.category?.type !== "income")
.reduce((sum, i) => sum + i.actual_amount, 0)
return { income, expenses, balance: income - expenses + (budget?.carryover_amount ?? 0) }
}, [items, budget?.carryover_amount])
const groupedItems = useMemo(() =>
CATEGORY_TYPES.map(type => ({
type,
items: items.filter(i => i.category?.type === type),
budgeted: items.filter(i => i.category?.type === type).reduce((s, i) => s + i.budgeted_amount, 0),
actual: items.filter(i => i.category?.type === type).reduce((s, i) => s + i.actual_amount, 0),
})).filter(g => g.items.length > 0),
[items])
// Pass totals and groupedItems as props; child components are pure
``` ```
### Pattern 2: Collapsible Category Sections via Radix Collapsible **Route change — `src/App.tsx`:**
**What:** Each category group (income, bills, variable expenses, debt, savings, investment) is wrapped in a `Collapsible.Root`. The always-visible trigger row shows the category label, color dot, and group-level budget/actual/difference totals. The collapsible content reveals the individual line-item table. ```
// Add /setup route
// Add WizardRoute guard that wraps the existing ProtectedRoute subtree:
// if needsSetup && path !== "/setup" → redirect to /setup
// if !needsSetup && path === "/setup" → redirect to /
**When to use:** The dashboard hybrid view — users need the summary at a glance without scrolling through every line item. Opening a section is an explicit drill-down action. <Route path="/setup" element={<ProtectedRoute><SetupWizardPage /></ProtectedRoute>} />
```
**Trade-offs:** Adds `open` state per section. Use `useState` per section (not global state — there are at most 6 sections). Do not persist open state in localStorage for v1; the sections should open fresh on each visit so the summary view is the default. **Wizard state — local `useState` only in `SetupWizardPage`:**
**Example:**
```typescript ```typescript
// CategorySection.tsx type WizardState = {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } step: 1 | 2 | 3
from "@/components/ui/collapsible" selectedPresets: string[] // preset IDs chosen in step 1
import { ChevronDown } from "lucide-react" amounts: Record<string, number> // presetId → budgeted amount (step 2)
import { useState } from "react"
interface CategorySectionProps {
type: CategoryType
budgeted: number
actual: number
items: BudgetItem[]
currency: string
}
export function CategorySection({ type, budgeted, actual, items, currency }: CategorySectionProps) {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center gap-3 rounded-lg border px-4 py-3 hover:bg-muted/40">
<span className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: categoryColors[type] }} />
<span className="font-medium">{t(`categories.types.${type}`)}</span>
<span className="ml-auto tabular-nums text-sm text-muted-foreground">
{formatCurrency(actual, currency)} / {formatCurrency(budgeted, currency)}
</span>
<ChevronDown className={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<BudgetLineItems items={items} currency={currency} type={type} />
</CollapsibleContent>
</Collapsible>
)
} }
``` ```
### Pattern 3: shadcn ChartContainer + ChartConfig for All Charts Wizard state is ephemeral. It lives in the page component and is discarded on unmount. No context, no global store.
**What:** Wrap every Recharts chart in shadcn's `ChartContainer` component. Define colors and labels in a `ChartConfig` object that references existing CSS variable tokens from `index.css` (`var(--color-income)`, `var(--color-bill)`, etc.). Do not hardcode hex values inside chart components. **Completion flow:** `handleFinish()` in `SetupWizardPage` runs:
1. `useCategories().create` × N (create one category row per selected preset)
2. `useTemplate().createItem` × N (create template items, each linked to the new categories)
3. `navigate("/")` — DashboardPage loads, `useFirstRunState` returns `needsSetup=false`
**When to use:** All three chart types (bar, horizontal bar, donut). This ensures charts automatically theme with the design system and dark mode works at zero extra cost. **Preset data — `src/data/presets.ts`:** Static array of common budget items (rent, salary, groceries, car insurance, etc.) with suggested amounts and category types. No DB table needed — this is compile-time data.
**Trade-offs:** Requires adding the shadcn `chart` component (`npx shadcn@latest add chart`). Minor wrapper overhead, but the CSS variable binding and tooltip consistency is worth it. **No new DB migration.** Wizard writes to existing `categories` and `template_items` tables.
**New files:**
- `src/hooks/useFirstRunState.ts`
- `src/pages/SetupWizardPage.tsx`
- `src/components/wizard/WizardStep.tsx`
- `src/components/wizard/CategoryDefaults.tsx`
- `src/components/wizard/TemplateDefaults.tsx`
- `src/data/presets.ts`
**Modified files:**
- `src/App.tsx` — add `/setup` route + WizardRoute guard
---
### 2. Auto-Budget Creation on Month Visit
**Problem:** Visiting the dashboard for a new month shows an empty state with two manual buttons ("Create Budget", "Generate from Template"). This friction contradicts the "just works" goal.
**Solution:** Auto-trigger `generateFromTemplate` inside a `useEffect` in `DashboardPage` when no budget exists for the current month and the user's template has items.
**Integration point — `src/pages/DashboardPage.tsx`:**
Add `useTemplate()` call alongside the existing `useBudgets()` call. Add a `useEffect` with these guards:
**Example:**
```typescript ```typescript
// IncomeBarChart.tsx const { items: templateItems } = useTemplate()
import { ChartContainer, ChartTooltip, ChartTooltipContent } const hasTemplateItems = templateItems.length > 0
from "@/components/ui/chart" const isCurrentMonth = month === currentMonth // currentMonth from useMonthParam baseline
import { BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts" const attempted = useRef(false)
const chartConfig = { useEffect(() => {
budgeted: { label: "Budgeted", color: "var(--color-income)" }, if (
actual: { label: "Actual", color: "var(--color-income)" }, !loading &&
} satisfies ChartConfig !currentBudget &&
hasTemplateItems &&
// data: [{ month: "March", budgeted: 3000, actual: 2850 }] isCurrentMonth &&
export function IncomeBarChart({ data, currency }: Props) { !attempted.current &&
return ( !generateFromTemplate.isPending
<ChartContainer config={chartConfig} className="min-h-[200px] w-full"> ) {
<BarChart data={data}> attempted.current = true
<CartesianGrid vertical={false} /> generateFromTemplate.mutate({ month: parsedMonth, year: parsedYear, currency })
<XAxis dataKey="month" /> }
<YAxis tickFormatter={v => formatCurrency(v, currency)} /> }, [loading, currentBudget, hasTemplateItems, isCurrentMonth])
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="budgeted" fill="var(--color-income)" radius={4} />
<Bar dataKey="actual" fill="var(--color-income)" fillOpacity={0.6} radius={4} />
</BarChart>
</ChartContainer>
)
}
``` ```
### Pattern 4: PageShell for Consistent Page Headers **Currency source:** The `generateFromTemplate` mutation currently accepts `currency` as a param. Pull the user's preferred currency from the profile via a `useProfile` hook or extend `useAuth` to expose it. If `useProfile` is not yet implemented, fall back to reading from the most recent budget's `currency` field, or default to the settings page value.
**What:** A `PageShell` component accepts `title`, `description` (optional), and `action` (optional ReactNode slot for a primary CTA button). Every page wraps its top section in `PageShell`. This enforces a consistent heading size, spacing, and CTA placement across the entire app refresh. **No new API endpoint.** The `generateFromTemplate` mutation in `useBudgets.ts` already handles the full create + seed flow server-side. This is a pure frontend trigger change.
**When to use:** All 9 pages in the overhaul. Any new page added in future milestones should also use it. **Edge cases handled by the guards:**
- Template has 0 items → `hasTemplateItems=false` → skip auto-create, show prompt to configure template
- Budget already exists → `!currentBudget=false` → skip
- Past/future month navigation → `isCurrentMonth=false` → skip (only auto-create for today's month)
- Multiple renders before mutation completes → `attempted` ref prevents double-fire
**Trade-offs:** Adds one wrapper per page. The benefit is that a single visual change to the page header propagates everywhere without hunting through 9 files. **Modified files:**
- `src/pages/DashboardPage.tsx` — add useTemplate(), useEffect trigger, currency source
---
### 3. Inline Add-from-Category-Library (Replaces QuickAdd Page)
**Problem:** QuickAdd is a separate nav page (`/quick-add`), a separate table (`quick_add_items`), and a Popover on the Dashboard. Three disconnected surfaces for one concept. The mental model is unclear.
**Solution:** Replace the QuickAdd Popover with an `AddOneOffSheet` component (a shadcn Sheet/side panel) available directly on BudgetDetailPage and Dashboard. It shows the user's category list grouped by type. The user picks a category, enters an amount, and a `one_off` budget item is created. The `quick_add_items` table remains intact but is removed from the primary workflow.
**New component — `src/components/budget/AddOneOffSheet.tsx`:**
**Example:**
```typescript ```typescript
// shared/PageShell.tsx interface AddOneOffSheetProps {
interface PageShellProps { budgetId: string
title: string open: boolean
description?: string onOpenChange: (open: boolean) => void
action?: React.ReactNode
children: React.ReactNode
} }
// Uses: useCategories() + useBudgets().createItem
// On save: createItem.mutate({ budgetId, category_id, budgeted_amount, actual_amount, notes, item_tier: "one_off" })
```
export function PageShell({ title, description, action, children }: PageShellProps) { This replaces `QuickAddPicker.tsx` at both call sites. The existing `useBudgets().createItem` mutation is unchanged — it already inserts `item_tier: "one_off"` budget items.
return (
<div className="flex flex-col gap-6"> **QuickAdd page fate:** Remove from `AppLayout.tsx` nav items array. Keep the route and page file in place (backwards compat, existing data). Do NOT drop the `quick_add_items` table or `useQuickAdd` hook.
<div className="flex items-start justify-between gap-4">
<div> **Modified files:**
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> - `src/components/AppLayout.tsx` — remove QuickAdd from `navItems` array
{description && ( - `src/pages/DashboardPage.tsx` — replace `<QuickAddPicker>` with `<AddOneOffSheet>`
<p className="text-sm text-muted-foreground mt-1">{description}</p> - `src/pages/BudgetDetailPage.tsx` — replace `<QuickAddPicker>` with `<AddOneOffSheet>`
)}
</div> **New files:**
{action && <div className="shrink-0">{action}</div>} - `src/components/budget/AddOneOffSheet.tsx`
</div>
{children} **No DB migration needed.** `one_off` budget items already exist in `budget_items` with `item_tier = 'one_off'`.
</div>
) ---
}
### 4. Dashboard Simplification
**Problem:** The current dashboard renders three chart types (donut + two bar charts) in a 3-column grid plus collapsible sections. This is dense and the charts don't clearly reflect user-entered data. v2.0 goal: "this month's budget at a glance."
**Solution:** Remove the 3-column chart grid from `DashboardContent`. Keep `SummaryStrip` (3 KPI cards) and `CollapsibleSections`. The chart components stay in the codebase — they are correct — but are removed from the Dashboard layout.
**Modified files:**
- `src/pages/DashboardPage.tsx` — remove chart grid `<div>` and the three chart imports from `DashboardContent`
**Dashboard data correctness:** The aggregation logic (`totalIncome`, `totalExpenses`, `budgetedIncome`, `budgetedExpenses`) is already correct in `DashboardContent`. The perceived "data not reflecting input" issue is caused by the auto-create flow not existing yet — once a budget exists and items are populated, the existing calculations are accurate. No aggregation logic changes needed.
**Empty state after removing charts:** The removed chart area frees vertical space. Collapsible sections become the primary view. The `SummaryStrip` at the top provides the quick-glance summary. Consider adding a single "at a glance" progress indicator to `SummaryStrip` to compensate for the removed chart density.
---
### 5. Design System Token Rework
**Problem:** `--radius: 0.625rem` produces rounded corners everywhere. The palette has high chroma values that read as saturated rather than pastel.
**Solution:** Lower the radius token and tune OKLCH lightness/chroma in `index.css`. All shadcn/ui components reference `--radius` via `rounded-[--radius]` — a single value change propagates everywhere.
**The only changed file: `src/index.css`**
Key changes:
```css
/* Sharp edges */
--radius: 0.125rem; /* was 0.625rem */
/* Background: warmer, closer to pure white */
--color-background: oklch(0.99 0.003 260); /* was 0.98 0.005 260 */
/* Softer border */
--color-border: oklch(0.91 0.008 260); /* was 0.88 0.01 260 */
/* Category pastels: increase lightness slightly, keep chroma */
/* e.g. --color-income-fill: oklch(0.72 0.14 155) → 0.78 0.12 155 */
```
**What does NOT change:**
- Token names (e.g., `--color-income`, `--color-bill`) — `palette.ts` references these by name
- Two-tier pattern (text tokens at ~0.55L, fill tokens at ~0.680.78L)
- Category color identities (income=green, bill=orange-red, etc.)
- The `@theme inline` structure
**shadcn component customization:** Zero component-file changes required. Radius change propagates automatically. Color changes propagate via CSS var resolution.
---
## Updated System Layout (v2.0)
```
┌─────────────────────────────────────────────────────────────────────┐
│ React 19 SPA │
│ │
│ Auth Layer App Layer │
│ ┌───────────────┐ ┌─────────────────────────────────────────┐ │
│ │ LoginPage │ │ WizardRoute (NEW) │ │
│ │ RegisterPage │ │ └─ ProtectedRoute + AppLayout (MOD) │ │
│ └───────────────┘ │ Sidebar (5 nav items, -QuickAdd) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ New route /setup: │
│ SetupWizardPage (NEW) → 3 steps → writes categories + template │
│ │
│ Modified pages: │
│ DashboardPage (MOD: auto-create, -charts, AddOneOffSheet) │
│ BudgetDetailPage (MOD: AddOneOffSheet replaces QuickAddPicker) │
│ │
│ Unchanged pages: │
│ CategoriesPage TemplatePage BudgetListPage SettingsPage │
│ QuickAddPage (hidden from nav, route kept) │
│ │
│ New components: │
│ wizard/WizardStep wizard/CategoryDefaults wizard/TemplateDefaults│
│ budget/AddOneOffSheet │
│ │
│ New hooks: │
│ useFirstRunState │
│ │
│ New data: │
│ src/data/presets.ts (static, no DB) │
└─────────────────────────┬───────────────────────────────────────────┘
│ @supabase/supabase-js v2 (unchanged)
┌─────────────────────────▼───────────────────────────────────────────┐
│ Supabase (unchanged schema, no new migrations) │
│ All new features write to existing tables │
└─────────────────────────────────────────────────────────────────────┘
``` ```
--- ---
## Data Flow ## Component Boundary Map (v2.0)
### Dashboard Read Flow
``` ```
DashboardPage renders App.tsx
├── /login → LoginPage
useBudgets() → finds current month budget → passes budgetId prop ├── /register → RegisterPage
├── /setup → ProtectedRoute > SetupWizardPage (NEW)
DashboardContent mounts │ ├── WizardStep (NEW)
├── step 1: CategoryDefaults (NEW) — checkboxes from presets.ts
useBudgetDetail(budgetId) → TanStack Query cache or Supabase fetch │ └── step 2: TemplateDefaults (NEW) — amount inputs per selected preset
↓ data arrives └── /* → WizardRoute > ProtectedRoute > AppLayout (MODIFIED: -QuickAdd nav)
useMemo recalculates: totals, groupedItems, chartData ├── / → DashboardPage (MODIFIED)
│ ├── MonthNavigator
Props flow DOWN to pure presentational children: │ ├── DashboardContent (MODIFIED: no chart grid)
SummaryStrip(totals) │ │ ├── SummaryStrip (unchanged)
ChartPanel(chartData, currency) │ │ ├── CollapsibleSections (unchanged)
├── IncomeBarChart(barData) │ │ └── AddOneOffSheet (NEW, replaces QuickAddPicker)
├── ExpenseDonutChart(pieData) ├── /categories → CategoriesPage (unchanged)
── SpendBarChart(horizontalData) ── /template → TemplatePage (unchanged)
CategorySection[] (groupedItems, per-type items) ├── /budgets → BudgetListPage (unchanged)
── BudgetLineItems(items, currency) ── /budgets/:id → BudgetDetailPage (MODIFIED)
│ └── AddOneOffSheet (NEW, replaces QuickAddPicker)
├── /quick-add → QuickAddPage (hidden from nav, route kept)
└── /settings → SettingsPage (unchanged)
``` ```
### Budget Item Edit Flow (unchanged, flows back up) ---
## Data Flow Changes
### First-Run + Wizard Completion Flow
``` ```
InlineEditCell: user types new actual_amount New user lands on any protected route
onCommit → updateItem.mutateAsync({ id, budgetId, actual_amount }) WizardRoute: useFirstRunState() → needsSetup=true
Supabase updates budget_items row redirect /setup
onSuccess: queryClient.invalidateQueries(["budgets", budgetId, "items"]) SetupWizardPage renders (step 1)
User checks preset categories (Rent, Salary, Groceries, ...)
↓ [Next]
step 2: User adjusts amounts per selected category
↓ [Finish] → handleFinish()
useBudgetDetail re-fetches items useCategories().create × N → INSERT categories (N rows)
↓ await
useTemplate().createItem × N → INSERT template_items (N rows)
↓ await
navigate("/")
DashboardContent useMemo recalculates all derived values DashboardPage: useFirstRunState() → needsSetup=false
Auto-create effect fires (see below)
ALL children rerender with consistent new data
``` ```
### State Management (what lives where) ### Auto-Budget Creation Flow
| State | Location | Why | ```
|-------|----------|-----| DashboardPage mounts (month = "2026-04", current month)
| Budget and items data | TanStack Query cache | Server state, must survive component unmounts |
| Collapsible open/closed | `useState` in each `CategorySection` | Purely local UI state; 6 booleans maximum | useBudgets() → budgets list → currentBudget = undefined
| Chart tooltip hover | Recharts internal | Library-managed interaction state | useTemplate() → items → hasTemplateItems = true
| Dialog open/closed | `useState` in page components | Unchanged from current pattern |
| Currency, locale | `Profile` via Supabase → hooks | Read from `budget.currency`; no separate state | useEffect fires:
!loading && !currentBudget && hasTemplateItems && isCurrentMonth && !attempted
generateFromTemplate.mutate({ month: 4, year: 2026, currency: "EUR" })
Supabase:
1. INSERT budgets (start_date=2026-04-01, end_date=2026-04-30)
2. SELECT template_items WHERE template_id = user's template
3. INSERT budget_items × N (actual_amount=0 per item)
onSuccess: invalidate ["budgets"], ["budgets", id], ["budgets", id, "items"]
DashboardPage rerenders: currentBudget = new budget
DashboardContent mounts (keyed by budgetId)
SummaryStrip + CollapsibleSections render with template-populated data
```
### Inline Add-One-Off Flow
```
DashboardPage or BudgetDetailPage
"Add one-off" button → AddOneOffSheet opens (Sheet side panel)
useCategories() provides category list (grouped by type)
User picks category, enters amount, optional notes
[Add] → useBudgets().createItem.mutate({
budgetId,
category_id,
budgeted_amount: amount,
actual_amount: amount, // one-off: budget = actual at time of entry
notes,
item_tier: "one_off"
})
onSuccess: invalidate ["budgets", budgetId, "items"]
Sheet closes → item appears in appropriate CollapsibleSection
```
--- ---
## Scaling Considerations ## New vs Modified File Summary
This is a personal finance app for a single authenticated user at a time. Scale is not a concern for rendering. The relevant concern is **perceived performance** on the dashboard when `items` is large (100+ line items). ### New Files
| Scale | Architecture Adjustment | | File | Purpose |
|-------|--------------------------| |------|---------|
| <50 items (normal) | No optimization needed — current useMemo pattern is fast | | `src/hooks/useFirstRunState.ts` | Detect first-run: categories=0 OR template_items=0 |
| 50-200 items | useMemo already handles this — O(n) passes are negligible | | `src/data/presets.ts` | Static preset category list with suggested amounts |
| 200+ items | Consider React.memo on CategorySection to skip unchanged sections; still no virtualization needed | | `src/pages/SetupWizardPage.tsx` | Multi-step first-run wizard (local state, 3 steps) |
| Dashboard load time | TanStack Query 5-min staleTime means instant rerender on navigate-back; no change required | | `src/components/wizard/WizardStep.tsx` | Step wrapper with step indicator / progress |
| `src/components/wizard/CategoryDefaults.tsx` | Preset category checkbox picker (step 1) |
| `src/components/wizard/TemplateDefaults.tsx` | Amount inputs per selected preset (step 2) |
| `src/components/budget/AddOneOffSheet.tsx` | Sheet-based one-off item adder (replaces QuickAddPicker) |
### Modified Files
| File | What Changes |
|------|-------------|
| `src/App.tsx` | Add `/setup` route; add WizardRoute guard |
| `src/components/AppLayout.tsx` | Remove QuickAdd from `navItems` array |
| `src/pages/DashboardPage.tsx` | Add useTemplate(); add auto-create useEffect; remove chart grid; swap AddOneOffSheet for QuickAddPicker |
| `src/pages/BudgetDetailPage.tsx` | Swap AddOneOffSheet for QuickAddPicker |
| `src/index.css` | Lower `--radius`; tune OKLCH pastel token values |
| `src/i18n/en.json` | Add wizard i18n keys; remove/rename quick-add nav key |
| `src/i18n/de.json` | Same additions as en.json (bilingual requirement) |
### Unchanged Files (confirmed)
`useBudgets.ts`, `useTemplate.ts`, `useCategories.ts`, `useQuickAdd.ts`, `useAuth.ts`, `useMonthParam.ts`, `lib/types.ts`, `lib/palette.ts`, `lib/format.ts`, `lib/supabase.ts` — all hooks and library files are read-only for this milestone.
--- ---
## Anti-Patterns ## Build Order (Dependency-Aware)
### Anti-Pattern 1: Deriving Chart Data Inside Chart Components Phase dependencies must be respected. Build bottom-up.
**What people do:** Put `items.filter(...).reduce(...)` directly inside `IncomeBarChart` or `ExpenseDonutChart`, passing the raw `items` array from `useBudgetDetail` as a prop. **Phase 1: Design Tokens (no deps)**
- `src/index.css` — lower `--radius`, refine OKLCH pastel values
- Verify all 9 existing pages render correctly; no logic changes
- Fast and reversible — do this first to establish visual baseline
**Why it's wrong:** Each chart component recalculates from scratch. If `items` reference changes (after a mutation), all three charts recalculate independently. Chart components become impure, harder to test, and cannot be reused with different data shapes without changing their internals. **Phase 2: Preset Data + First-Run Hook (no deps)**
- `src/data/presets.ts` — static data, no imports needed
- `src/hooks/useFirstRunState.ts` — depends on existing `useCategories` + `useTemplate`; no UI yet
**Do this instead:** Derive all chart data in `DashboardContent` with `useMemo`. Pass prepared `barData`, `pieData`, `horizontalData` arrays to each chart. Charts receive typed data arrays and render only. **Phase 3: Setup Wizard (depends on Phase 2)**
- `src/components/wizard/WizardStep.tsx` — pure presentational
- `src/components/wizard/CategoryDefaults.tsx` — depends on presets.ts
- `src/components/wizard/TemplateDefaults.tsx` — depends on presets.ts
- `src/pages/SetupWizardPage.tsx` — assembles wizard components + useFirstRunState + mutation hooks
- `src/App.tsx` — wire `/setup` route + WizardRoute guard
**Phase 4: Auto-Budget Creation (depends on Phase 3 — template must be populatable)**
- `src/pages/DashboardPage.tsx` — add `useTemplate()` import + auto-create `useEffect`
- Test: wizard → dashboard → budget auto-creates → SummaryStrip shows template data
**Phase 5: Inline Add-One-Off + Dashboard Simplification (depends on Phase 4)**
- `src/components/budget/AddOneOffSheet.tsx` — depends on `useCategories` + `useBudgets` (both existing)
- `src/pages/DashboardPage.tsx` — remove chart grid; wire AddOneOffSheet
- `src/pages/BudgetDetailPage.tsx` — wire AddOneOffSheet
- `src/components/AppLayout.tsx` — remove QuickAdd nav item
--- ---
### Anti-Pattern 2: Hardcoding Colors Inside Chart Components ## Anti-Patterns to Avoid
**What people do:** Paste hex values like `fill="#4ade80"` into `<Bar>` and `<Cell>` components to match the design. ### Anti-Pattern 1: Global State for Wizard
**Why it's wrong:** The existing `index.css` already defines category colors as OKLCH CSS variables (`--color-income`, `--color-bill`, etc.) and `palette.ts` maps them to `var(--color-income)` etc. Hardcoding breaks dark mode adaptation, creates a second source of truth, and means a palette change requires editing multiple files. **What people do:** Store wizard state in a React context, Zustand store, or TanStack Query mutation cache.
**Why it's wrong:** Wizard runs once, on first login. Global state persists unnecessarily and creates cleanup complexity.
**Do this instead:** Local `useState` in `SetupWizardPage`. When the page unmounts (after `navigate("/")`), state is automatically cleaned up.
**Do this instead:** Always reference `categoryColors[type]` from `palette.ts` (which returns the CSS variable string) for both chart fills and UI color dots. For shadcn `ChartContainer`, pass `color: categoryColors[type]` in the `ChartConfig` object. ### Anti-Pattern 2: Auto-Create Without Template Guard
**What people do:** Trigger `generateFromTemplate` whenever `!currentBudget`, regardless of template state.
**Why it's wrong:** If a user skips the wizard or has an empty template, an empty budget gets silently created. This is worse than the empty state — it looks like a bug, not a fresh start.
**Do this instead:** Guard with `hasTemplateItems && isCurrentMonth`. Show a helpful CTA to configure the template if template is empty.
### Anti-Pattern 3: Dropping the quick_add_items Table
**What people do:** Add a migration to drop `quick_add_items` when the QuickAdd nav item is removed.
**Why it's wrong:** Existing users have data there. The DB migration is irreversible. The table costs nothing to keep.
**Do this instead:** Remove the nav item and surface only. Leave the table, `useQuickAdd` hook, and `/quick-add` route in place. Schedule a cleanup migration in a future milestone after confirming no users rely on it.
### Anti-Pattern 4: Renaming Design Token CSS Variables
**What people do:** Rename tokens during the design rework (e.g., `--color-income``--category-color-income`) to make the naming more semantic.
**Why it's wrong:** `palette.ts` references token names as strings: `var(--color-income)`. All chart components use `var(--color-income-fill)`. Renaming causes silent CSS failures — no TypeScript error, no build error, just missing colors.
**Do this instead:** Change only the VALUES of existing tokens. Add new token names only for net-new concepts.
### Anti-Pattern 5: Wizard as Modal Overlay
**What people do:** Show the wizard as a Dialog or Sheet over the main app layout on first load.
**Why it's wrong:** No URL, so refresh drops the user back to the start or the main app with incomplete setup. Focus management in a full-screen modal is complex. Back button behavior is broken.
**Do this instead:** Dedicated `/setup` route. `WizardRoute` handles redirect logic. The browser URL reflects wizard state, refresh works, and no special cleanup is needed.
### Anti-Pattern 6: Adding useEffect Dependencies by Feel
**What people do:** Add `generateFromTemplate` itself to the `useEffect` deps array.
**Why it's wrong:** Mutation objects from TanStack Query are re-created on every render, causing the effect to re-run in a loop.
**Do this instead:** Use a `useRef` guard (`attempted.current`) and gate on stable primitive values (`!loading`, `!currentBudget`, `hasTemplateItems`). Do not include the mutation object in the dependency array.
--- ---
### Anti-Pattern 3: One Monolithic DashboardContent Component ## Supabase / DB Notes (v2.0)
**What people do:** Add all new dashboard sections — summary cards, three charts, six collapsible sections, QuickAdd button — directly into one large `DashboardContent.tsx` that becomes 400+ lines. **No new migrations required.** All v2.0 features write to existing tables:
**Why it's wrong:** The existing `DashboardContent` is already 200 lines with just two charts and progress bars. A full hybrid dashboard with three chart types and six collapsible sections will exceed 600 lines inline, making it impossible to review, test, or modify individual sections without reading the whole file. | Feature | Writes To | Via |
|---------|-----------|-----|
| Wizard | `categories`, `template_items` | existing `useCategories().create`, `useTemplate().createItem` |
| Auto-budget | `budgets`, `budget_items` | existing `useBudgets().generateFromTemplate` |
| Inline one-off | `budget_items` | existing `useBudgets().createItem` |
| Design tokens | — | CSS only |
**Do this instead:** Extract into the component tree defined above. `DashboardContent` orchestrates layout and owns derived data. Each distinct visual section (`SummaryStrip`, `ChartPanel`, `CategorySection`) is its own file. The rule: if a block of JSX has a distinct visual purpose, it gets its own component. The `quick_add_items` table becomes read-only from the primary UI flow. No rows deleted, no schema change.
--- ---
### Anti-Pattern 4: Using shadcn Accordion Instead of Collapsible for Category Sections ## Integration Points: New vs Existing
**What people do:** Reach for the `Accordion` component (which is already in some shadcn setups) to build collapsible category sections because it looks similar. | New Feature | Existing Hooks/Mutations Used | New Additions |
|-------------|------------------------------|---------------|
**Why it's wrong:** Accordion by default enforces "only one open at a time" (type="single") or requires explicit `type="multiple"` with `collapsible` prop to allow free open/close. For budget sections, users may want to compare two categories side-by-side with both open simultaneously. Using individual `Collapsible` per section gives full independent control without fighting Accordion's root-state coordination. | First-run wizard | `useCategories().create`, `useTemplate().createItem` | `useFirstRunState`, `presets.ts`, wizard components |
| Auto-budget creation | `useBudgets().generateFromTemplate` (already exists) | `useEffect` trigger in DashboardPage, `hasTemplateItems` guard |
**Do this instead:** Use Radix `Collapsible` (shadcn wrapper) on each `CategorySection` with independent `useState`. Six independent booleans are trivially managed. | Inline one-off add | `useBudgets().createItem` (already exists), `useCategories()` | `AddOneOffSheet` component |
| Dashboard simplification | `useBudgetDetail`, `useMonthParam`, `SummaryStrip`, `CollapsibleSections` | Remove 3-column chart grid from DashboardContent |
--- | Design tokens | All existing components consume tokens automatically | Token value adjustments in `index.css` only |
### Anti-Pattern 5: Modifying Hooks or Supabase Queries
**What people do:** Add derived fields or aggregations to the `useBudgetDetail` return value, or add new Supabase query fields, because it seems convenient during the UI overhaul.
**Why it's wrong:** Explicitly out of scope (PROJECT.md: "No Supabase schema changes — UI-only modifications"). Any hook changes risk breaking `BudgetDetailPage` and other consumers. The data model is sufficient — all needed aggregations can be computed with `useMemo` from the existing `items` array.
**Do this instead:** Keep hooks read-only. All computation lives in the presentation layer via `useMemo`.
---
## Integration Points
### External Services
| Service | Integration | Notes |
|---------|-------------|-------|
| Supabase | Unchanged — hooks layer handles all DB calls | No new RPC calls, no schema changes |
| Recharts | Chart primitives — wrap with `ChartContainer` | Requires adding shadcn `chart` component |
| Radix UI | `Collapsible` primitive — add via shadcn CLI | Not yet in project; must be added before CategorySection work |
| i18next | All new UI text needs keys in `en.json` and `de.json` | Add keys before rendering any new text; no runtime fallback acceptable |
### Internal Boundaries
| Boundary | Communication | Notes |
|----------|---------------|-------|
| `DashboardContent` ↔ chart components | Props only — typed data arrays + currency string | Charts are pure; they do not call hooks |
| `DashboardContent``CategorySection` | Props only — grouped items, budgeted/actual totals, currency, mutation handlers | Mutation handlers passed down from `DashboardContent` which owns `useBudgets()` mutations |
| `CategorySection``BudgetLineItems` | Props only — items array, currency, type | `BudgetLineItems` is a thin table wrapper; all mutations are callbacks from parent |
| `PageShell` ↔ all pages | Props (title, action slot, children) | No state shared; purely compositional |
| `index.css` @theme tokens ↔ components | CSS variables via `var(--color-X)` and `palette.ts` | Single source of truth for all color; never duplicate in component style props |
---
## Build Order Implications
Components have dependencies that dictate implementation order:
1. **`components/ui/collapsible.tsx`** — Add via `npx shadcn@latest add collapsible`. Required by `CategorySection`. Do first.
2. **`components/ui/chart.tsx`** — Add via `npx shadcn@latest add chart`. Required by all chart components. Do first.
3. **`shared/PageShell.tsx`** — No dependencies. Build early; apply to all pages as each is refreshed.
4. **`StatCard` + `SummaryStrip`** — Only depends on `formatCurrency` and Tailwind. Build second after primitives.
5. **Chart components** (`IncomeBarChart`, `ExpenseDonutChart`, `SpendBarChart`) — Depend on `ChartContainer`. Build after step 2.
6. **`ChartPanel`** — Composes the three chart components. Build after step 5.
7. **`BudgetLineItems`** — Refactored from existing `BudgetDetailPage` table code; existing `InlineEditCell` and `DifferenceCell` are reusable as-is.
8. **`CategorySection`** — Depends on `Collapsible` primitive and `BudgetLineItems`. Build after steps 1 and 7.
9. **`DashboardContent`** — Orchestrates everything. Build last, wiring together all children. Replace the existing `DashboardContent` function in `DashboardPage.tsx`.
10. **Non-dashboard page refreshes** — Apply `PageShell` + visual refresh to remaining 7 pages. Independent of dashboard work; can be done in any order.
--- ---
## Sources ## Sources
- Radix UI Collapsible: https://www.radix-ui.com/primitives/docs/components/collapsible (HIGH confidence — official docs) - Full codebase inspection (April 2026):
- shadcn/ui Chart component: https://ui.shadcn.com/docs/components/radix/chart (HIGH confidence — official docs) - `src/App.tsx`, `src/components/AppLayout.tsx`
- shadcn/ui Accordion: https://ui.shadcn.com/docs/components/radix/accordion (HIGH confidence — official docs) - `src/pages/DashboardPage.tsx`, `src/pages/TemplatePage.tsx`, `src/pages/BudgetDetailPage.tsx`
- Tailwind CSS v4 @theme tokens: https://tailwindcss.com/docs/theme (HIGH confidence — official docs) - `src/hooks/useTemplate.ts`, `src/hooks/useBudgets.ts`, `src/hooks/useQuickAdd.ts`, `src/hooks/useMonthParam.ts`
- React useMemo: https://react.dev/reference/react/useMemo (HIGH confidence — official docs) - `src/components/QuickAddPicker.tsx`, `src/components/shared/PageShell.tsx`
- Existing codebase: inspected `src/` fully — `DashboardPage.tsx`, `BudgetDetailPage.tsx`, `CategoriesPage.tsx`, `AppLayout.tsx`, `palette.ts`, `types.ts`, `index.css`, `useBudgets.ts` (HIGH confidence — primary source) - `src/lib/types.ts`, `src/lib/palette.ts`
- `src/index.css`, `src/i18n/en.json`
- `supabase/migrations/002_categories.sql` through `005_quick_add.sql`
- Confidence: HIGH — all claims are grounded in verified source code, not assumptions
--- ---
*Architecture research for: SimpleFinanceDash UI overhaul — React + Tailwind + shadcn/ui + Recharts* *Architecture research for: SimpleFinanceDash v2.0 — wizard setup, auto-budget, inline add, design rework*
*Researched: 2026-03-16* *Researched: 2026-04-02*

View File

@@ -1,16 +1,24 @@
# Feature Research # Feature Research
**Domain:** Personal finance budget dashboard (UI presentation layer overhaul) **Domain:** Personal budget app — wizard-driven setup, auto-budget creation, simplified monthly tracking UX
**Researched:** 2026-03-16 **Researched:** 2026-04-02
**Confidence:** HIGH — grounded in competitor analysis (YNAB, Empower), industry design guides, and direct codebase inspection **Confidence:** MEDIUM — grounded in competitor analysis (YNAB, Monarch, EveryDollar, Actual Budget, Quicken Simplifi, Copilot) and UX/fintech design literature. Some specifics are inferred from behavioral patterns; no first-party user testing data.
--- ---
## Context ## Context
This research covers the **presentation layer** only. Backend schema and data model are frozen. The question is: what UI/UX features make a personal finance dashboard feel polished rather than basic, and which of those apply to this project's existing data model (income, bills, variable expenses, debt, savings, investments — budgeted vs actual)? This research covers v2.0 UX Simplification. The existing app already has all the data infrastructure — categories (6 types), template system, monthly budget generation, quick-add library, dashboard with charts. The problem is cognitive: too many disconnected concepts, friction in getting a budget running, and no guided experience for new users.
The existing stack is React 19 + Tailwind CSS 4 + Recharts + shadcn/ui. All chart capabilities already exist in Recharts; this research identifies how to use them well. The research question: **How do personal budget apps handle first-run wizard setup, auto-creation from template, and simplified monthly views?** What is table stakes, what differentiates, and what should be avoided?
Existing features that remain and are not re-researched here:
- Category CRUD
- Template item management
- Budget detail with inline editing
- Charts (donut, bar, horizontal bar)
- Collapsible dashboard sections
- Settings (locale, currency)
--- ---
@@ -18,23 +26,18 @@ The existing stack is React 19 + Tailwind CSS 4 + Recharts + shadcn/ui. All char
### Table Stakes (Users Expect These) ### Table Stakes (Users Expect These)
Features users assume exist. Missing these = product feels incomplete. Features users assume exist. Missing these = product feels incomplete or broken.
| Feature | Why Expected | Complexity | Notes | | Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------| |---------|--------------|------------|-------|
| Summary KPI cards at the top | Every finance dashboard leads with top-line numbers (income, total spend, balance). Users scan these first — it's the entry point to understanding the month. | LOW | Already exists but needs richer visual treatment: larger numbers, colored icons, clearer hierarchy | | Wizard-style first-run setup | Every modern app (YNAB, EveryDollar, Monarch) guides new users through initial setup — no blank-slate drop. Blank-state is a known retention killer for budget apps. | MEDIUM | Multi-step flow: income → recurring items → review. Must be skippable per step. |
| Green/red semantic color coding for over/under budget | Finance convention: green = on track, red = over budget. Violating this creates cognitive friction. | LOW | Already exists on progress bars and difference cells; must be consistent across all new chart types | | Pre-filled category items with common defaults | Users expect "rent," "groceries," "car insurance" to already be there — not to type everything from scratch. YNAB's Beginner Template and Quicken Simplifi both provide starter category lists. | LOW | Pre-seed the wizard with a curated list of ~15-20 common items grouped by type. User selects/deselects, edits amounts. |
| Donut/pie chart for expense category breakdown | Standard visualization for "how is spending distributed." Users expect to see the breakdown at a glance. | LOW | Existing pie is plain; needs inner radius (donut), center total label, and richer color fills | | Auto-created budget on first month visit | After setup, users expect a budget to exist for the current month without a separate "generate budget" step. The manual trigger pattern is a v1-era pattern that creates friction. Quicken Simplifi does this with its Spending Plan. | MEDIUM | Trigger auto-generation server-side when a user visits the current month and no budget exists. |
| Budget vs actual per category | The core comparison every budget tool offers. Users come here specifically to compare what they planned to what they actually spent. | MEDIUM | Existing progress bars satisfy this minimally; new grouped/horizontal bar charts will fulfill it properly | | Ability to skip setup and start minimal | Power users and returning users want to start blank or skip ahead. Forcing everyone through a wizard is patronizing. | LOW | Each wizard step needs a "Skip" option. Entire wizard skippable via "I'll set this up later." |
| Inline value editing on budget detail | Users need to log actuals inline — no one wants a separate form for every line item. | LOW | Already implemented in BudgetDetailPage as InlineEditCell; keep and refine | | Inline add-from-library on the budget view | Users expect to add a one-off expense from their familiar item list without navigating away. The current separate Quick-Add page is a disconnect. Spendee and Actual Budget surface this inline. | MEDIUM | Replace Quick-Add page with an inline panel/sheet triggered from the budget view. Category + item picker, amount field, confirm. |
| Loading state that reflects page structure | If the page renders blank or flashes, users distrust the data or think it's broken. | LOW | Currently returns `null` during load — skeleton cards matching the final layout are expected | | Monthly budget shows budgeted vs actual, grouped | The core interaction loop: open budget, see what's in each category, enter actuals. Every app (YNAB, EveryDollar, Actual Budget) organizes this as grouped rows with a budgeted amount and an actuals field. | LOW | Already exists in the current BudgetDetailPage. Table stakes means this must work correctly and clearly — not changed, just verified. |
| Empty state with actionable guidance | A blank dashboard for a new month should tell the user what to do next, not just show nothing. | LOW | Currently shows plain muted text; needs a CTA card pattern | | Dashboard = this month's budget at a glance | Users want to open the app and immediately know their status: income, spending, balance. Not a chart gallery. Monarch and Copilot both lead with a "current month" summary card. | LOW | Dashboard must surface current month's data prominently — summary cards first, then detail. Already partially true; needs data correctness fix. |
| Tabular-numeral formatting throughout | Financial amounts must use tabular (monospaced number) alignment so columns read correctly. | LOW | Already applied via `tabular-nums`; extend to all new components | | Empty state with clear call to action | If no template is set up, the app must explain what to do — not just show a blank page. "Set up your budget template" with a single action button. | LOW | Applies to: empty template page, empty budget view, empty dashboard. |
| Color-coded category identity | Users build a mental map: bills are orange, savings are blue. Consistent color per category type is required everywhere — charts, tables, badges. | LOW | Palette already defined in `index.css` and `palette.ts`; extend consistently to new components |
| Collapsible / grouped sections per category type | Budget detail and dashboard both group items by type. Users expect to expand/collapse groups, especially with many line items. | MEDIUM | BudgetDetailPage already groups by type; dashboard needs collapsible inline sections as new feature |
| Month navigation on the dashboard | Users want to check a prior month without navigating away. "Looking at last month" is a top-3 use case for any budget tool. | MEDIUM | Not in current dashboard (auto-resolves current month only); needs month selector |
| Totals row / footer per section | Standard pattern from spreadsheets. Each category group needs a sub-total visible without counting rows. | LOW | Already in BudgetDetailPage table footer; needs equivalent on dashboard sections |
| Consistent design language across all pages | Auth pages, settings, categories, and budget list must feel like the same product as the dashboard. Inconsistency signals amateur work. | HIGH | Currently only dashboard is styled; all other pages need the same card/color/typography treatment |
### Differentiators (Competitive Advantage) ### Differentiators (Competitive Advantage)
@@ -42,16 +45,12 @@ Features that set the product apart. Not required, but valued.
| Feature | Value Proposition | Complexity | Notes | | Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------| |---------|-------------------|------------|-------|
| Grouped bar chart: income budgeted vs actual | Directly answers "Did I earn what I expected this month?" in a single visual. Most basic dashboards skip this, showing only expense charts. | MEDIUM | Recharts `BarChart` with two `Bar` components (budgeted / actual); use category colors | | Smart amount suggestions during wizard setup | Rather than blank amount fields, prefill with sensible defaults (e.g., $1,500 for rent, $400 for groceries) that users can override. Reduces the "I don't know what to put here" paralysis. Most apps leave this blank. | LOW | Can be static defaults (not AI). Store suggested amounts per item type in a constant/config. User edits freely. |
| Horizontal bar chart: spend by category type (budget vs actual) | Lets users scan over-budget categories at a glance. Horizontal orientation makes labels readable without rotation. | MEDIUM | Recharts `BarChart layout="vertical"` with `XAxis type="number"` and `YAxis type="category"` | | "Start from your income" wizard framing | Frame setup as: "First, how much do you earn each month?" then "Here are common expenses people like you track." Income-first anchoring mirrors zero-based budgeting mental model (YNAB's core concept) and makes subsequent amounts feel grounded. | LOW | First wizard step = income amount. Subsequent steps show remaining balance updating as items are added — "you have $X left to allocate." |
| Donut chart with center total label | Center label turns the chart from decorative into informational — shows total spend while the ring shows distribution. YNAB and Empower both do this. | LOW | Recharts `Label` component inside `Pie` using `viewBox` coordinates for positioned text | | Running balance display during wizard | As the user adds items in the wizard, show "Remaining to allocate: $X" updating live. Makes it immediately obvious if they're over-allocating. This is YNAB's core interaction loop, applied to setup. | LOW | Derived from: income total minus sum of bill/expense/debt/saving/investment items. Frontend computation only. |
| Active sector hover highlight on donut | Hovering a slice expands it slightly and shows the category name + amount in a tooltip. Feels interactive and polished vs a static chart. | LOW | Recharts `activeShape` with expanded `outerRadius` — supported natively | | Persistent sidebar "this month" widget on dashboard | A small always-visible card showing remaining balance for the month, top 2-3 categories near limit, and a "View full budget" link. Eliminates the need to navigate away to check status. Copilot uses this pattern. | MEDIUM | Requires current month budget query always running. Can be a sticky sidebar panel on the dashboard page rather than a global element. |
| Variance indicator (delta arrows) | Show "▲ 12% over" or "▼ 5% under" beside actual amounts on summary cards and section headers. Transforms raw numbers into directional insight. | LOW | Computed from budgeted/actual; render as small colored badge or arrow icon | | Auto-create silently, notify only on first creation | First-time users see a brief notification "Your March budget was created from your template." Subsequent months: silently created, no notification. This is the right default — zero friction for users who've set up their template. | LOW | Backend: check if budget for current month exists on page load; create if not. Frontend: show a toast only if a new budget was just created. |
| Accent-colored card borders / icon containers | Rich visual style: each category section gets its palette color as a left border accent or icon container fill. Breaks the sea-of-grey-cards look. | LOW | Tailwind `border-l-4` or `bg-[color]/10` container with category CSS variable | | Template edit directly accessible from monthly view | If a user wants to permanently change a recurring item (e.g., rent went up), they should be able to jump to the template from the budget view without hunting through the nav. | LOW | An "Edit template" link on the budget page or a per-item "Save to template" action. Single nav jump, no modal complexity. |
| Section collapse persisted in localStorage | Remembering which sections are open avoids users re-expanding the same sections every visit. Small touch that signals quality. | LOW | Simple `useState` + `localStorage` read/write; wrap in a `useCollapse` hook |
| Carryover amount visible on dashboard | The budget model already tracks `carryover_amount`. Showing it alongside the available balance makes the "running total" story clear. | LOW | Surface as a fourth summary card or sub-label on the balance card |
| Skeleton loading that mirrors the real layout | Animated skeleton cards/charts during load feel like the app is fast and predictable. Blank screens feel broken. | LOW | shadcn/ui `Skeleton` component already available; wrap summary cards and chart containers |
| Page header with budget month name | Every page in the app should show context: "March 2026 Budget." Users confirm they're looking at the right period immediately. | LOW | Format `budget.start_date` as "Month YYYY" in the page heading |
### Anti-Features (Commonly Requested, Often Problematic) ### Anti-Features (Commonly Requested, Often Problematic)
@@ -59,91 +58,87 @@ Features that seem good but create problems in this context.
| Feature | Why Requested | Why Problematic | Alternative | | Feature | Why Requested | Why Problematic | Alternative |
|---------|---------------|-----------------|-------------| |---------|---------------|-----------------|-------------|
| Trend / multi-month charts | Users always ask "can I see my spending over 6 months?" | Requires multi-budget data aggregation, a new query shape, and a new chart layout — out of scope for a UI-only overhaul. Adding it half-baked is worse than not adding it. | Explicitly defer to a future milestone. Put a placeholder card with "Coming soon" if needed. | | AI-suggested amounts based on spending history | "Smart defaults should learn from me over time" | Requires enough historical data (users don't have it at setup), an ML model or LLM call, and privacy tradeoffs. The payoff is low when static defaults work fine for first-run. | Static curated defaults per item type. After 3+ months of use, a "Review your template" prompt is sufficient. |
| Dark mode toggle | Professional apps always have a dark mode. | Tailwind CSS 4 dark mode via class strategy is possible, but requires auditing every component's contrast, chart colors, and skeleton states. Doing it properly doubles testing surface. The OKLCH color system is set up with dark mode in mind, but no dark palette is currently defined. | Define as a future phase deliverable. Keep the existing light-only approach consistent and polished first. | | Mandatory wizard (no escape) | "Force users to complete setup so they have a working budget" | Patronizing for returning users; creates frustration for users who want to explore before committing. YNAB's old onboarding got negative feedback for being too locked-in; they added a flexible checklist in Oct 2025 for this reason. | Skippable wizard with a prominent resume-later CTA. Show a "Complete setup" banner if template is empty. |
| Real-time sync / live updates | "What if two tabs are open?" | No backend changes allowed this milestone. Supabase realtime would require schema changes and subscription setup. The current TanStack Query polling pattern is sufficient. | Rely on query invalidation on mutation — already in place. | | Auto-sync with bank accounts | "Automatically populate actuals from transactions" | Plaid/bank integrations add significant infrastructure cost, security scope, and maintenance burden. Out of scope for a self-hosted personal tool. | Manual actual entry (inline editing, already implemented) with optional import as a future feature. |
| Drag-to-reorder line items | Power users want custom sort order | Category `sort_order` field exists, but updating it requires PATCH calls and animation logic. No backend changes in scope. | Keep current `sort_order` from DB; do not expose drag handles this milestone. | | Complex wizard with 6+ steps | "Guide the user through every aspect of budgeting" | Drop-off increases sharply with each step beyond 3. Users who see step 4 of 7 abandon setup entirely. | Max 3 steps: (1) Income, (2) Recurring items, (3) Review + confirm. Everything else is editable post-setup. |
| Glassmorphism / heavy blur effects | Trendy, visually striking | `backdrop-blur` on many elements is GPU-heavy and can degrade performance on lower-end machines. Also risks reducing text readability on charts. Misapplied, it obscures data rather than enhancing it. | Use solid cards with subtle colored-border accents instead. Reserve `backdrop-blur` for a single hero element if at all. | | Per-item recurrence configuration in wizard | "Let users set whether each item is weekly, monthly, etc." | Adds decision burden during setup. The app model is monthly budgeting — every item is implicitly monthly. Edge cases (weekly, biweekly) should be post-setup configuration. | Monthly-only assumption during wizard. Users can edit frequency on the template page after setup. |
| AI / natural language budget insights | Feels modern, seen in apps like Copilot | Requires an AI backend, a new API integration, and user data being sent to a third-party service — none of which are part of a UI-only overhaul. | Surface static insights from computed data instead: "You spent 15% more on bills than budgeted this month." Computed in the frontend, no AI needed. | | Gamification / streaks for budget tracking | "Engage users to check in daily" | Budget apps that add gamification often feel gimmicky for a personal finance tool. The target user is tracking a spreadsheet replacement — they want data, not achievements. | Progress indicators (spent X% of budget) provide motivation without game mechanics. |
| Infinite scroll on budget list | "I have many months of budgets" | Adds complexity to the budget list query and requires scroll position management. The budget list is unlikely to have more than 24 entries in a year of use. | Standard paginated or full list with a search/filter input is sufficient. | | Duplicate "Quick Add" page alongside inline add | "Keep the existing page and add inline too" | Two ways to do the same thing creates confusion about which to use. The standalone Quick Add page has no added value over an inline approach. | Replace entirely with inline add-from-library on the budget view. Remove the nav link for the old Quick Add page. |
--- ---
## Feature Dependencies ## Feature Dependencies
``` ```
Donut Chart with Center Label Wizard Setup
└──requires──> Category color system (already exists) └──requires──> Pre-seeded library items (common defaults in DB or seed data)
└──requires──> Recharts Label component in Pie (Recharts native) └──requires──> Template CRUD (already exists)
└──produces──> Populated template (unlocks auto-creation)
└──enhances──> Smart amount suggestions (display layer only)
└──enhances──> Running balance during wizard (computed from income + items)
Horizontal Bar Chart (budget vs actual by type) Auto-Budget Creation
└──requires──> Category color system (already exists) └──requires──> Populated template (wizard must complete, or manual template setup)
└──requires──> Grouped data by category type (already in DashboardContent) └──requires──> Backend trigger logic (check if budget for month exists; create if not)
└──produces──> Current month budget (unlocks simplified budget view)
└──enhances──> Silent creation toast (frontend notification, optional)
Grouped Bar Chart (income budget vs actual) Simplified Budget View (inline add from library)
└──requires──> Income items filtered from BudgetItems (already in DashboardContent) └──requires──> Current month budget exists (auto-creation must fire first)
└──requires──> Category library with quick-add items (already exists as quick_add_items table)
└──replaces──> Standalone Quick Add page (remove from nav)
└──enhances──> Budget detail (adds inline picker panel alongside existing inline editing)
Collapsible Dashboard Sections Dashboard = This Month at a Glance
└──requires──> Category group data (already derived) └──requires──> Current month budget exists (auto-creation must fire first)
└──enhances──> Section collapse persistence (localStorage) └──requires──> Budget data correctness fix (tracked separately)
└──enhances──> Totals row per section (already exists in BudgetDetailPage) └──depends-on──> Simplified budget view (users need to be able to add actuals quickly)
Skeleton Loading Template Edit from Budget View
└──requires──> Final layout structure (must design layout first) └──requires──> Template page (already exists)
└──depends-on──> Summary cards layout (build cards first, then wrap in Skeleton) └──enhances──> Simplified budget view (provides escape hatch to change recurring items)
Month Navigation on Dashboard
└──requires──> Budgets list query (already in useBudgets)
└──conflicts──> Auto-resolve current month (replace with explicit selection)
Consistent Design Language (all pages)
└──depends-on──> Dashboard redesign being finished first (establishes the design token/component pattern)
└──applies-to──> LoginPage, RegisterPage, CategoriesPage, TemplatePage, BudgetListPage, BudgetDetailPage, QuickAddPage, SettingsPage
Variance Indicators (delta arrows)
└──requires──> budgeted_amount and actual_amount both available (already on BudgetItem)
└──enhances──> Summary cards (add % change sub-label)
└──enhances──> Collapsible section headers (show group variance at a glance)
``` ```
### Dependency Notes ### Dependency Notes
- **Skeleton loading requires final layout:** Don't implement skeleton states until the target card/chart layout is finalized — skeleton shape must mirror real content shape. - **Wizard depends on pre-seeded library:** The wizard presents pre-filled common items. These must exist in the database (or a constants file) before the wizard can show them. This is the foundational data concern for v2.0.
- **All-pages redesign depends on dashboard:** The dashboard establishes the color system application patterns (accent borders, card styles, typography scale) that all other pages will inherit. - **Auto-creation depends on template having content:** If the user skips the wizard entirely, auto-creation generates an empty budget (no items). The UI must handle this gracefully — show empty-state with "Set up your template" CTA.
- **Month navigation conflicts with auto-resolve:** The current pattern auto-finds the budget for the current calendar month. Adding a selector means the dashboard must hold local `selectedBudgetId` state rather than computing it. - **Inline add replaces Quick Add page:** This is a replacement, not an addition. The Quick Add page should be removed from the nav. The backend endpoint it uses can be repurposed or kept as the inline add trigger.
- **Donut center label requires Recharts Label:** shadcn/ui's chart system supports this natively via the `Label` component inside `Pie` using `viewBox` coordinates. No new library needed. - **Dashboard correctness is a prerequisite:** The "dashboard = this month at a glance" feature is only meaningful if the data it displays is accurate. Data correctness fix must happen before or alongside the dashboard simplification.
--- ---
## MVP Definition ## MVP Definition
This is a UI overhaul milestone, not a greenfield product. "MVP" here means: what must ship for the redesign to feel complete and polished? This is v2.0 of an existing working app. "MVP" here means: what is the minimum set of changes that delivers the simplified UX without regressions?
### Launch With (v1 — Dashboard Overhaul) ### Launch With (v2.0 — Core UX Simplification)
- [ ] Summary cards with richer visual treatment (larger numbers, semantic color on balance, variance badge) — foundation of the dashboard - [ ] Wizard-style template setup — 3-step flow: income → common items (pre-filled, editable) → review — skippable
- [ ] Donut chart with center total label and active sector hover — replaces existing flat pie - [ ] Pre-seeded library with ~15-20 common items grouped by category type (rent, car insurance, groceries, utilities, car payment, etc.)
- [ ] Horizontal bar chart (budget vs actual by category type) — new chart, satisfies the key "am I on track" question - [ ] Smart amount defaults in wizard (static sensible values per item — not AI)
- [ ] Grouped bar chart (income budget vs actual) — completes the three-chart suite from the reference - [ ] Running balance during wizard (income minus sum of selected items)
- [ ] Collapsible inline sections per category group on dashboard (with line items + group totals) — replaces current progress bars - [ ] Auto-create current month budget from template on first visit (backend trigger, silent)
- [ ] Skeleton loading states for cards and charts — removes the jarring blank-then-rendered experience - [ ] Toast notification on first auto-creation only ("Your April budget was created from your template")
- [ ] Month navigation control (budget period selector) on dashboard — without this the dashboard is locked to current month only - [ ] Inline add-from-library on the budget view (replaces Quick Add page)
- [ ] Remove Quick Add from navigation
- [ ] Dashboard summary cards correctly reflect current month budget data
- [ ] Empty state CTAs for: empty template, empty dashboard, empty budget
### Add After Dashboard Phase (v1.x — Full App Polish) ### Add After Core Ships (v2.x)
- [ ] Consistent card/color design applied to BudgetDetailPage — users navigate here from the dashboard; it must match - [ ] "Edit template" shortcut from budget view — once core flow is working, add the escape hatch
- [ ] Consistent design applied to BudgetListPage — entry point from nav, feels disconnected from dashboard without polish - [ ] Persistent "this month" summary widget on dashboard — adds value once data is correct
- [ ] Consistent design applied to CategoriesPage and TemplatePage — setup pages; lower urgency but visible gap - [ ] "Complete setup" banner for users who skipped wizard but have empty template
- [ ] Consistent design applied to LoginPage and RegisterPage — first impression; polish matters - [ ] Wizard resume-later state (save partial wizard progress) — only needed if drop-off is observed
- [ ] Consistent design applied to QuickAddPage and SettingsPage — utility pages; can ship slightly after core flows
- [ ] Section collapse state persisted to localStorage — nice-to-have UX polish
### Future Consideration (v2+) ### Future Consideration (v3+)
- [ ] Trend charts over multiple months — deferred explicitly in PROJECT.md out-of-scope - [ ] Income-based spending recommendations (percentages by category type)
- [ ] Dark mode — foundational work (OKLCH variables) exists but needs full audit and dark palette definition - [ ] "Review your template" prompt after 3 months of use
- [ ] AI-derived spending insights — requires backend changes; no scope here - [ ] CSV/bank import for actuals — mentioned as future feature in PROJECT.md
- [ ] Drag-to-reorder categories — requires sort_order mutation support - [ ] Recurring transaction automation — mentioned as future in PROJECT.md
--- ---
@@ -151,94 +146,116 @@ This is a UI overhaul milestone, not a greenfield product. "MVP" here means: wha
| Feature | User Value | Implementation Cost | Priority | | Feature | User Value | Implementation Cost | Priority |
|---------|------------|---------------------|----------| |---------|------------|---------------------|----------|
| Summary cards — richer visual treatment | HIGH | LOW | P1 | | Wizard setup (3-step, skippable) | HIGH | MEDIUM | P1 |
| Donut chart with center label + active hover | HIGH | LOW | P1 | | Pre-seeded library items (15-20 defaults) | HIGH | LOW | P1 |
| Horizontal bar chart (spend vs budget by type) | HIGH | MEDIUM | P1 | | Smart amount defaults in wizard | MEDIUM | LOW | P1 |
| Grouped bar chart (income budget vs actual) | HIGH | MEDIUM | P1 | | Running balance during wizard | HIGH | LOW | P1 |
| Collapsible dashboard sections with line items | HIGH | MEDIUM | P1 | | Auto-create budget on month visit | HIGH | MEDIUM | P1 |
| Skeleton loading states | MEDIUM | LOW | P1 | | Dashboard data correctness fix | HIGH | MEDIUM | P1 |
| Month navigation on dashboard | HIGH | MEDIUM | P1 | | Inline add-from-library on budget view | HIGH | MEDIUM | P1 |
| Consistent design on BudgetDetailPage | HIGH | MEDIUM | P1 | | Remove Quick Add page from nav | MEDIUM | LOW | P1 |
| Consistent design on BudgetListPage | MEDIUM | LOW | P2 | | Empty state CTAs (template, dashboard, budget) | MEDIUM | LOW | P1 |
| Variance indicators (delta arrows/badges) | MEDIUM | LOW | P2 | | Silent creation with first-time toast | MEDIUM | LOW | P2 |
| Accent-colored category section borders | MEDIUM | LOW | P2 | | "Edit template" link from budget view | MEDIUM | LOW | P2 |
| Carryover amount visible on dashboard | MEDIUM | LOW | P2 | | Persistent "this month" dashboard widget | MEDIUM | MEDIUM | P2 |
| Consistent design on auth/settings/utility pages | LOW | MEDIUM | P2 | | "Complete setup" banner for empty template | LOW | LOW | P2 |
| Section collapse persisted in localStorage | LOW | LOW | P3 | | Wizard resume-later persistence | LOW | MEDIUM | P3 |
| Empty state with action guidance | MEDIUM | LOW | P2 |
**Priority key:** **Priority key:**
- P1: Must have for the overhaul to feel complete - P1: Must ship for v2.0 to feel complete
- P2: Should have; include in same milestone if effort allows - P2: Should have; include if effort allows
- P3: Nice to have; add as polish after core delivery - P3: Nice to have; future consideration
--- ---
## Competitor Feature Analysis ## Competitor Feature Analysis
| Feature | YNAB | Empower (formerly Personal Capital) | Our Approach | | Feature | YNAB | Monarch Money | EveryDollar | Quicken Simplifi | Our Approach |
|---------|------|--------------------------------------|--------------| |---------|------|---------------|-------------|------------------|--------------|
| Summary KPI cards | Yes — "Available" balance prominent | Yes — net worth headline | 3 cards: Income / Expenses / Balance | | First-run wizard | Flexible checklist (Oct 2025 update); Beginner Template to import categories | Guided category setup on signup; AI auto-categorizes after account link | Wizard-style setup; named "best for newbies" | Template import + Spending Plan auto-setup | 3-step wizard: income → items → review |
| Budget vs actual bars | Yes — horizontal category bars with color (green/yellow/red) | Cash flow bars | Horizontal bar chart per category type | | Pre-filled defaults | Beginner Template with common categories (mortgage, groceries, vacation) | Default category list; user trims | Pre-loaded category list | Category templates to import | ~15-20 curated items, editable in wizard |
| Donut / pie chart | No (YNAB uses different visualization) | Yes — allocation donut for investments | Donut with center total, colored by category type | | Auto-budget creation | Manual monthly allocation required ("assign every dollar") | Spending Plan auto-projects from past data + recurring detected | Manual each month; no auto-create | Spending Plan auto-calculates and updates | Auto-create from template on month visit; silent |
| Collapsible grouped sections | Yes — master categories expand to show sub-categories | No — flat list | Collapsible per category type (income, bills, etc.) | | Add expense inline vs separate | Inline per category row; no separate page | Inline transaction entry | Separate transaction entry form | Inline via Spending Plan category rows | Inline panel on budget view; Quick Add page removed |
| Inline editing | Yes — click amount to edit | No | Keep existing InlineEditCell pattern, refine styling | | Dashboard focus | "To be budgeted" balance + category status | Monthly spending summary + goals | Zero-based plan + remaining | Spending Plan summary + bills calendar | Summary cards (income/expense/balance) + current month budget |
| Color-coded categories | Yes — status colors (green/yellow/red) | Category colors for accounts | Per-type semantic color (income=green, debt=red/pink, etc.) | | Skippable setup | Yes (since Oct 2025 checklist) | Yes | Limited | Yes | Yes — every step skippable |
| Month/period navigation | Yes — budget period selector in sidebar | Yes — date range selector | Month selector on dashboard |
| Skeleton loading | Yes — YNAB shows skeleton on load | Yes | Skeleton cards + chart placeholders |
| Variance / delta indicators | Yes — shows "over by $X" inline | Yes — shows gain/loss % | Variance badge on summary cards and section headers |
--- ---
## Chart Design Notes (Recharts-Specific) ## Wizard Flow Design Notes
These translate research findings into concrete implementation guidance for the Recharts stack. Based on research into YNAB, EveryDollar, and UX onboarding best practices, the recommended wizard structure is:
**Donut chart:** **Step 1 — Income**
- Use `innerRadius={60}` to `innerRadius={72}` for a modern ring look (not too thin) - Single question: "What's your monthly take-home income?"
- Place center total using `<Label>` with `content` prop inside `<Pie>` using `viewBox` cx/cy coordinates - Single number input + currency formatting
- Use `activeShape` with `outerRadius + 10` for hover expansion - Skip option: "I'll add this later"
- Add `isAnimationActive` to respect `prefers-reduced-motion` - Sets the anchor for running balance in step 2
- Legend as a separate `<ul>` below the chart (not Recharts built-in legend) for full styling control
**Horizontal bar chart (budget vs actual by type):** **Step 2 — Recurring Items**
- `<BarChart layout="vertical">` with `<XAxis type="number">` and `<YAxis type="category">` - Pre-loaded list of common items grouped by type (bills, variable expenses, debts, savings, investments)
- Two `<Bar>` components — one for budgeted (muted/secondary color) and one for actual (category color) - Each item has a checkbox (on by default for high-likelihood items, off for lower-likelihood)
- Set `barSize` to keep bars compact; use `barCategoryGap` for breathing room - Editable amount field per item (prefilled with static sensible default)
- `<LabelList position="right">` for actual amounts visible on bar ends - Running balance updates live: "Remaining to allocate: $X"
- Never start X axis at a non-zero value - "Add custom item" inline for items not in the list
- Skip option: "I'll set this up manually"
**Grouped bar chart (income budget vs actual):** **Step 3 — Review**
- Standard `<BarChart>` with `layout="horizontal"` (default) - Summary of selected items by group
- Two `<Bar>` groups: budgeted (muted fill) and actual (category color) - Total income, total allocated, remaining
- `<CartesianGrid vertical={false}>` to reduce visual noise - "Create my template" CTA — writes to the template table
- Custom `<ChartTooltip>` showing both values with currency formatting - "Go back" link to step 2
**Color system:** **Post-wizard:**
- All charts must consume CSS variables from the existing OKLCH palette (`var(--color-income)`, etc.) - Auto-create current month budget from template (immediate)
- Never hardcode hex values in chart components — use the `categoryColors` map from `palette.ts` - Show toast: "Your [Month] budget has been created"
- Green/red semantic colors for over/under-budget states must be distinct from category colors - Land on budget view for current month
**Confidence:** MEDIUM — pattern validated by YNAB (beginner template import), EveryDollar (wizard-style), and UX onboarding research. 3-step max is validated by drop-off research (complexity above 3 steps correlates with abandonment).
---
## Inline Add-From-Library Design Notes
The existing Quick Add page uses a library of one-off items (`quick_add_items` table). In v2.0, this interaction moves inline to the budget view.
**Recommended pattern (based on fintech UX research):**
- Trigger: "Add item" button in each category section header on the budget view
- Mechanism: A slide-in panel or modal sheet (not a full page navigation)
- Contents: Category pre-selected (from which section was clicked); searchable list of library items for that category type; amount field; description field
- On confirm: Item added to budget for the month, totals update immediately
- On dismiss: Panel closes, no navigation
**Why panel/sheet over modal:**
- Modals block background context; users lose their place in the budget
- A slide-in sheet (shadcn/ui Sheet component) keeps the budget visible alongside the picker
- Smashing Magazine (2026): "modals work for quick confirmations; sheets work for contextual data entry tasks"
**Why not keep the separate Quick Add page:**
- Two surfaces for the same action creates confusion (which should I use?)
- The separate page breaks the flow — user must navigate away, losing their place
- Inline keeps the budget view as the single place for monthly budget work
--- ---
## Sources ## Sources
- [Fintech design guide with patterns that build trust — Eleken (2026)](https://www.eleken.co/blog-posts/modern-fintech-design-guide) - [YNAB Beginner Template and category setup](https://www.ynab.com/templates)
- [Finance Dashboard Design Best Practices — F9 Finance](https://www.f9finance.com/dashboard-design-best-practices/) - [YNAB new flexible checklist for mobile (Oct 2025)](https://www.ynab.com/whats-new)
- [Budget vs Actual Dashboard — Bold BI](https://www.boldbi.com/dashboard-examples/finance/budget-vs-actual-dashboard/) - [YNAB vs Copilot AI comparison 2025 — ZenFinanceAI](https://zenfinanceai.com/ynab-vs-copilot-ai/)
- [7 Essential Financial Charts for Personal Finance Visualization — Syncfusion](https://www.syncfusion.com/blogs/post/financial-charts-visualization) - [Monarch Money budget creation documentation](https://help.monarch.com/hc/en-us/articles/360048883631-Creating-Your-Budget-in-Monarch)
- [Fintech dashboard design — Merge Rocks](https://merge.rocks/blog/fintech-dashboard-design-or-how-to-make-data-look-pretty) - [Monarch vs EveryDollar comparison — BudgetCoachUSA](https://budgetcoachusa.com/monarch-vs-everydollar/)
- [Dashboard Design UX Patterns Best Practices — Pencil & Paper](https://www.pencilandpaper.io/articles/ux-pattern-analysis-data-dashboards) - [Top Personal Finance Apps with Customizable Budget Categories 2026 — Quicken](https://www.quicken.com/blog/top-personal-finance-apps-with-customizable-budget-categories/)
- [shadcn/ui Charts — Donut with center text, Bar charts](https://ui.shadcn.com/charts) - [Best Budget Apps 2026 — NerdWallet](https://www.nerdwallet.com/finance/learn/best-budget-apps)
- [Bar Charts Best Practices — Nastengraph / Medium](https://nastengraph.medium.com/bar-charts-best-practices-5e81ebc7b340) - [Best Budget Apps 2026 — Engadget](https://www.engadget.com/apps/best-budgeting-apps-120036303.html)
- [Best Color Palettes for Financial Dashboards — Phoenix Strategy Group](https://www.phoenixstrategy.group/blog/best-color-palettes-for-financial-dashboards) - [How Great Budget App Design Increases User Retention — Onething Design](https://www.onething.design/post/budget-app-design)
- [The Role of Color Theory in Finance Dashboard Design — Extej / Medium](https://medium.com/@extej/the-role-of-color-theory-in-finance-dashboard-design-d2942aec9fff) - [UX Onboarding Best Practices 2025 — UX Design Institute](https://www.uxdesigninstitute.com/blog/ux-onboarding-best-practices-guide/)
- [Skeleton loading screen design — LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/) - [Budget App Design Tips from Fintech Experts — Eleken](https://www.eleken.co/blog-posts/budget-app-design)
- [Empty state UX examples — Eleken](https://www.eleken.co/blog-posts/empty-state-ux) - [Modal vs. Separate Page: UX Decision Tree — Smashing Magazine](https://www.smashingmagazine.com/2026/03/modal-separate-page-ux-decision-tree/)
- [YNAB / Mint / Empower comparison — The State of Personal Finance Apps 2025](https://bountisphere.com/blog/personal-finance-apps-2025-review) - [Actual Budget — self-hosted open source](https://actualbudget.org/)
- [Recharts documentation — recharts.org](https://recharts.github.io/en-US/api/Bar/) - [App Onboarding Guide 2026 — UXCam](https://uxcam.com/blog/10-apps-with-great-user-onboarding/)
- [Fintech App Top 20 Financial UX Dos and Don'ts — UXDA](https://theuxda.com/blog/top-20-financial-ux-dos-and-donts-to-boost-customer-experience)
--- ---
*Feature research for: SimpleFinanceDash UI overhaul — presentation layer* *Feature research for: SimpleFinanceDash v2.0 — wizard setup, auto-budget creation, simplified monthly tracking UX*
*Researched: 2026-03-16* *Researched: 2026-04-02*

View File

@@ -1,182 +1,202 @@
# Pitfalls Research # Pitfalls Research
**Domain:** Personal finance dashboard UI overhaul (React SPA) **Domain:** Adding wizard setup, auto-budget creation, design system rework, and page consolidation to an existing personal budget app (React + Supabase + TanStack Query)
**Researched:** 2026-03-16 **Researched:** 2026-04-02
**Confidence:** HIGH (most findings verified against official docs, Recharts issues tracker, and Tailwind v4 official docs) **Confidence:** HIGH (based on direct codebase analysis + known patterns for wizard state, auto-creation concurrency, and design system rework in existing apps)
--- ---
## Critical Pitfalls ## Critical Pitfalls
### Pitfall 1: Recharts Re-renders Every Parent State Change ### Pitfall 1: Duplicate Budget Creation on Concurrent Auto-Create Calls
**What goes wrong:** **What goes wrong:**
The existing `DashboardPage.tsx` computes `pieData` and `progressGroups` directly in the render body — no `useMemo`. Every unrelated state change (e.g., a tooltip hover, a loading flag flip, QuickAddPicker opening) re-runs these array transforms and causes Recharts to diff its entire virtual DOM tree. Adding two more charts (bar chart, horizontal bar) multiplies this cost by 3x. With a large budget, each O(n) filter+reduce runs six times per render for each chart type. The v2.0 goal is "auto-create this month's budget on first visit." The current `generateFromTemplate` mutation in `useBudgets.ts` has no deduplication guard — it creates a budget row unconditionally. If the dashboard mounts, checks for a current-month budget, finds none, and fires the auto-create, there is a race: if the component re-renders (e.g., auth state settles, React Strict Mode double-invokes effects) or the user has two tabs open, two budget rows get created for the same month. Both have the same `start_date`/`end_date` and `user_id`, but different UUIDs. There is no unique constraint on `(user_id, start_date)` in the current `004_budgets.sql` migration.
**Why it happens:** **Why it happens:**
The existing single-chart dashboard is fast enough that the missing memoization is invisible. During the overhaul, three chart instances are added to the same render tree, and the dashboard becomes visually complex enough that parent re-renders happen more frequently (collapsible state toggles, hover events on multiple chart tooltips). Auto-create logic placed in a React component or effect reacts to data absence, but TanStack Query's `enabled` flag and mutation guards don't prevent double-invocation in Strict Mode or multi-tab scenarios. The "check then create" pattern is an inherently non-atomic operation in the current client-side architecture.
**How to avoid:** **How to avoid:**
- Wrap all chart data derivations in `useMemo` with explicit deps arrays: - Add a database unique constraint: `UNIQUE (user_id, start_date)` on the `budgets` table via a new migration. This makes duplicate creation fail at the DB level rather than silently succeeding.
```tsx - In the auto-create logic, use an upsert (INSERT ... ON CONFLICT DO NOTHING) rather than a plain INSERT, so concurrent calls are idempotent.
const pieData = useMemo(() => - Use TanStack Query's mutation state to gate the auto-create: only fire if `mutation.isIdle` and no existing budget is found, never fire inside a `useEffect` without a ref guard.
EXPENSE_TYPES.map(type => { ... }).filter(d => d.value > 0), - After auto-create, immediately invalidate the budgets list query so the new budget is fetched in the same round-trip.
[items, t]
)
```
- Wrap formatter callbacks passed to `<Tooltip formatter={...}>` in `useCallback`. New function references on every render force Recharts to re-render tooltip internals.
- Extract each chart into its own memoized sub-component (`React.memo`) so only the chart whose data changed re-renders.
- Create a category index Map once (in a hook or useMemo) and look up by key rather than `.filter()` per item across three charts.
**Warning signs:** **Warning signs:**
- React DevTools Profiler shows `DashboardContent` re-rendering on tooltip mouseover - Two budget entries appear for the same month on the BudgetListPage after first visit
- Multiple chart tooltips fighting each other (Recharts issues #281 / #1770) - `console.error` shows a Supabase `23505 unique_violation` if the constraint is added and the race is triggered
- "ResizeObserver loop limit exceeded" console errors when multiple `ResponsiveContainer` instances mount simultaneously - Dashboard shows "Budget already exists" error toasts intermittently on page load
**Phase to address:** Dashboard redesign phase (charts implementation) **Phase to address:** Auto-budget creation phase — add the DB constraint first, implement upsert-based creation second. Do not build the auto-create UI trigger before the constraint exists.
--- ---
### Pitfall 2: CSS Variable Scope — Recharts Cannot Read Tailwind `@theme inline` Variables ### Pitfall 2: Wizard State Lost on Browser Back/Refresh — No Persistence Strategy
**What goes wrong:** **What goes wrong:**
The existing `palette.ts` already uses `var(--color-income)` etc. as chart fill values. This works correctly because Tailwind v4's `@theme inline` inlines these as true CSS custom properties on `:root`. However, the risk is subtle: if any chart color is set via a Tailwind utility class string (e.g., `fill-[var(...)]`) rather than through the `style` prop or a direct CSS variable reference, Recharts SVG elements — which render outside the standard DOM paint context — may not resolve them correctly in all browsers. A multi-step wizard (step 1: pick categories, step 2: set amounts, step 3: confirm) built with local React state (`useState`) loses all progress if the user navigates away, refreshes, or presses the browser back button. For a setup wizard that may have 35 steps and requires selecting 1020 template items with amounts, this is a critical UX failure on first run. The user loses all work and must restart from step 0.
Additionally, dark mode: the current CSS has no dark-mode overrides for `--color-income` through `--color-investment`. If the overhaul adds a dark mode toggle, all chart colors will remain light-mode pastel on dark backgrounds, creating very poor contrast.
**Why it happens:** **Why it happens:**
Tailwind v4's `@theme inline` block defines variables at `:root` scope, but the dark mode variant uses `.dark` class scoping. Theme variables defined only in `@theme inline` are not automatically duplicated under `.dark {}`. Dark-mode overrides for semantic colors (background, foreground) exist in shadcn's generated block, but category colors were custom-added without dark variants. The natural implementation is `const [step, setStep] = useState(0)` and `const [selections, setSelections] = useState([])` inside the wizard component. React state is ephemeral — it does not survive unmount. This works fine for modals but fails for a first-run setup wizard that the user may pause mid-way.
**How to avoid:** **How to avoid:**
- Pass all Recharts color values via JavaScript (the `fill={categoryColors[type]}` pattern is correct — maintain it). - Persist wizard state to `localStorage` keyed by user ID (e.g., `wizard_state_${userId}`) — read on mount, write on every step change, clear on completion/skip.
- If dark mode is added in the overhaul: add a `.dark {}` block in `index.css` that overrides `--color-income`, `--color-bill`, etc. with darker/brighter variants appropriate for dark backgrounds. - Use a simple serializable state shape: `{ step: number, selections: { categoryId, amount, tier }[] }`.
- Never attempt to pass Tailwind utility class strings as Recharts `fill` props. Recharts `Cell`, `Bar`, `Line` props require resolved color values (hex, oklch string, or `var()` reference). - On wizard mount: check localStorage first; if partial state exists, resume from the saved step with a "Continue where you left off" prompt.
- Test chart colors in both light and dark modes before marking a phase complete. - On wizard completion or explicit skip: clear the localStorage key.
- Do NOT use URL params to store full wizard state (too much data); use URL only to track the current step number (e.g., `?setup-step=2`) for browser back/forward support.
**Warning signs:** **Warning signs:**
- Chart fills show as `oklch(...)` literal text in DOM attributes instead of resolved colors - Wizard always starts at step 1 even after the user had reached step 3
- Colors are invisible or white-on-white on one theme variant - User reports losing selected categories after an accidental page reload
- Browser DevTools shows SVG `fill` as unresolved `var(--color-income)` with no computed value - No `localStorage` keys being set/read during wizard interaction
**Phase to address:** Design token / theming phase (early); dashboard charts phase (verification) **Phase to address:** Wizard setup phase — define the persistence strategy before building any step components. The storage format must be finalized before the first step is wired.
--- ---
### Pitfall 3: Collapsible Sections Causing Layout Shift and CLS Jank ### Pitfall 3: Design Token Rework Breaks Existing Pages Silently
**What goes wrong:** **What goes wrong:**
The planned hybrid dashboard includes collapsible inline sections for each category group (income, bills, variable expenses, debt, savings). If these are implemented by toggling `display: none` / `display: block` or by animating the `height` CSS property from `0` to `auto`, the result is either: (a) instant snap with no animation, or (b) jank where the browser triggers full layout recalculations on every animation frame. With five collapsible sections and charts above them, collapsing a section causes the charts to resize (their `ResponsiveContainer` detects parent width change), triggering a cascade of resize events. The v2.0 design system rework changes CSS variables in `index.css` (border-radius tokens from rounded to sharp, color palette from current OKLCH pastels to sharper/clearer ones, spacing adjustments). Because every existing page consumes these global tokens, any change to `--radius`, `--background`, `--foreground`, `--border`, or the six category color variables cascades to all 9 pages simultaneously. Changing `--radius` from `0.5rem` to `0px` sharps every Button, Card, Input, and Badge across the app in one line — but it also breaks charts (Recharts SVG `rx` attributes don't follow CSS variables) and any component that had visual design crafted around rounded corners.
**Why it happens:** **Why it happens:**
Animating `height` from `0` to `auto` is a known browser limitation — you cannot CSS-transition to `height: auto`. Common naive workarounds (JavaScript measuring `scrollHeight` on every frame) cause layout thrashing. Radix UI's `Collapsible` component handles this correctly via CSS custom properties for height, but requires the `data-state` attribute pattern and the correct CSS transition on the inner `CollapsibleContent` element. The current system uses shadcn/ui's CSS variable convention which is explicitly global-scope. Every component that uses `rounded-lg` (which resolves to `var(--radius)`) will change simultaneously. Pages that were "done" in v1.0 become visually broken the moment a global token changes.
**How to avoid:** **How to avoid:**
- Use Radix UI `Collapsible` (already available via shadcn) — it sets `--radix-collapsible-content-height` as a CSS variable during open/close, enabling smooth CSS-only transition: - Before touching any tokens, audit which existing pages use `--radius`, `--border`, and `--background` implicitly (via Tailwind utilities like `rounded-md`, `border`, `bg-background`). This is every page.
```css - Change tokens in isolation with a visual regression pass on ALL 9 pages before touching any page's component code. The token change should be one commit; the per-page fixup should be separate commits.
[data-state='open'] > .collapsible-content { - For the category color variables (`--color-income` through `--color-investment`), verify Recharts SVG fills still resolve after the change — these are passed as `fill={var(--color-income)}` in JS and require testing in both the browser DOM and printed output.
animation: slideDown 200ms ease-out; - Do not land the token rework mid-phase alongside page component changes — treat it as its own atomic commit.
}
[data-state='closed'] > .collapsible-content {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from { height: 0 }
to { height: var(--radix-collapsible-content-height) }
}
```
- Add `debounce={50}` to all `ResponsiveContainer` instances to prevent rapid resize recalculations when parent containers animate.
- Use `padding` instead of `margin` inside collapsible children — margin collapsing causes jump artifacts on some browsers.
- Never animate `height`, `padding`, or `margin` directly with JavaScript setInterval; use CSS animations or Radix primitives.
**Warning signs:** **Warning signs:**
- Charts visually "snap" to a narrower width and back when a section above them is toggled - Auth pages (login/register) have wrong border-radius after a `--radius` token change
- Frame rate drops to under 30fps when expanding/collapsing sections (visible in DevTools Performance panel) - Chart SVG fills appear correct in DevTools computed styles but wrong visually on screen
- `ResizeObserver loop limit exceeded` errors spike after section toggle - Any page that was marked "done" in v1.0 shows new visual regressions after token PR merges
**Phase to address:** Dashboard collapsible sections phase **Phase to address:** Design system foundation phase — the first phase of v2.0. Token rework must land before any page is rebuilt. Requires an explicit visual regression check of all existing pages immediately after the token commit.
--- ---
### Pitfall 4: Color Accessibility Failures in Financial Semantic Colors ### Pitfall 4: Quick-Add Page Removal Breaks Existing User Data and Nav References
**What goes wrong:** **What goes wrong:**
The most dangerous pattern in the existing dashboard is using pure green / red to signal positive / negative balance: The `quick_add_items` table and `QuickAddPage` exist in production. Existing users have data in this table (names, icons, sort_order). If the page is simply removed from routing and the nav (deleting `<Route path="quick-add" />` and the nav item in `AppLayout.tsx`) without a data migration or a clear deprecation path, those users:
```tsx 1. Lose access to their quick-add items (no UI)
const balanceColor = availableBalance >= 0 2. Have orphaned `quick_add_items` rows in the database that no longer serve any purpose
? "text-green-600 dark:text-green-400" 3. May hit broken bookmarks or history entries for `/quick-add`
: "text-red-600 dark:text-red-400"
```
And in the progress bar:
```tsx
const barColor = group.overBudget
? "bg-red-500 dark:bg-red-400"
: "bg-green-500 dark:bg-green-400"
```
Pure green (#00FF00) has a contrast ratio of only 1.4:1 against white — catastrophically below WCAG AA's 4.5:1 minimum for text. Even `green-600` (#16a34a) must be verified. Additionally, pie chart adjacent slice colors must maintain 3:1 contrast against each other (WCAG 2.1 SC 1.4.11 Non-text Contrast) — the existing six category colors are all similar lightness (OKLCH L ≈ 0.650.72), meaning they may be hard to distinguish for colorblind users or when printed. The new v2.0 model replaces quick-add with "add one-offs from category library directly in budget detail." This is a different data shape — category library items are categories, not quick_add_items. If a user had quick-add items that conceptually map to categories, that mapping is lost.
**Why it happens:** **Why it happens:**
Designers focus on making the palette "look rich" during an overhaul without running contrast checks. The redesign goal is "rich, colorful visual style" — this is a direct risk factor for accessibility regressions. Color-alone encoding (green = good, red = bad) violates WCAG 1.4.1 Use of Color, which requires that color not be the sole means of conveying information. Page removal feels like a simple delete-the-route operation. The data persistence and existing user considerations are invisible during development because the developer's test account may have no quick-add data.
**How to avoid:** **How to avoid:**
- Run every text color against its background through a WCAG contrast checker (target 4.5:1 for normal text, 3:1 for large text and UI components). Use the Tailwind oklch values — compute with tools like https://webaim.org/resources/contrastchecker/ - Audit: Does any existing user data in `quick_add_items` map to something meaningful in the new model? If quick-add items are just named shortcuts and the new model uses categories instead, the migration path is: surface existing quick-add items in the new "category library" flow, or clearly communicate to users that this data is being retired.
- For semantic colors (balance positive/negative, over-budget): supplement color with an icon or text label, not color alone. Example: a checkmark icon + green for positive balance; an exclamation icon + red for over-budget. - For the route: add a redirect `<Route path="quick-add" element={<Navigate to="/budgets" replace />} />` rather than leaving `/quick-add` as a 404. This handles bookmarks and history.
- For pie/donut chart slices: ensure adjacent colors have at least 3:1 contrast, or add a visible stroke separator (1px white/black stroke between slices provides a natural contrast boundary). - Remove the nav link in `AppLayout.tsx` at the same time as the redirect is added — never before.
- For the 6 category colors: vary hue and lightness, not just hue. Consider making the OKLCH lightness values more spread (e.g., 0.55 to 0.80 range) so colors are distinguishable at reduced color sensitivity. - Add a DB migration only if you decide to drop or repurpose the `quick_add_items` table. If keeping the table dormant, document why. If dropping it, confirm no FK references exist (none in the current schema — `budget_items` does not reference `quick_add_items`).
- Do not rely on `dark:text-green-400` having passed contrast automatically — verify each dark mode color pair independently.
**Warning signs:** **Warning signs:**
- All six category pie slices are clearly distinguishable to the developer but indistinguishable when the browser's "Emulate vision deficiency" filter is applied in DevTools - Developer tests removal on a fresh account with no quick-add data, misses the issue
- Running `window.getComputedStyle` on a colored element and checking its OKLCH L value — if multiple semantic colors cluster at the same lightness, colorblind users see identical grays - Browser history entries for `/quick-add` result in a blank or redirect-looping page
- `useQuickAdd` hook is still imported somewhere but the page is gone, causing a dead import
**Phase to address:** Design token / visual language phase (establish accessible palette before building components); then verify in each component phase **Phase to address:** Page consolidation phase (when BudgetDetailPage gets inline add-from-library). Remove the page only after the replacement flow is functional and tested with existing user data.
--- ---
### Pitfall 5: i18n Key Regressions When Renaming or Adding UI Sections ### Pitfall 5: Auto-Budget Creation Ignores User Currency Setting
**What goes wrong:** **What goes wrong:**
The current translation files (`en.json`, `de.json`) have flat keys per page section. The overhaul adds new dashboard sections (bar chart, horizontal bar chart, collapsible income/bill/expense groups, richer donut legend). Each new label, section header, tooltip, and ARIA label needs a corresponding key in both files. During rapid UI iteration, developers commonly add `t("dashboard.incomeSection")` to the JSX, forget to add it to `de.json`, and only notice when the German locale shows the raw key string — or worse, the i18next fallback silently shows the English value and the German regression goes undetected. The current `generateFromTemplate` mutation in `useBudgets.ts` accepts `currency?: string` with a default of `"EUR"`. If the auto-create logic fires without reading the user's profile `currency` field first, every auto-created budget will be denominated in EUR regardless of the user's settings. A user who has set their currency to USD or GBP in Settings will get EUR-denominated budgets. The `formatCurrency` calls will then display amounts in the wrong currency until the user manually edits the budget.
**Why it happens:** **Why it happens:**
`i18next` with `fallbackLng: 'en'` (the existing config) silently falls back to English when a German key is missing. There is no visible failure. The project has no i18n linting step and no build-time key extraction check. The `debug: false` production config makes this invisible. The auto-create is triggered by checking if a budget exists for the current month and calling `generateFromTemplate({ month, year })` — the `currency` parameter is optional and defaults silently. During development on a EUR-default account, this works fine and the bug is invisible.
**How to avoid:** **How to avoid:**
- When adding a new UI section, add its keys to **both** `en.json` and `de.json` in the same commit — never split across commits. - The auto-create trigger must first load the user's profile (from the `profiles` table, `currency` column) and pass that value explicitly: `generateFromTemplate({ month, year, currency: profile.currency })`.
- Use `i18next-cli` or `i18next-scanner` (npm package) to extract all `t("...")` call keys from source and diff against the JSON files. Run this as a pre-commit check or part of the build. - The `useProfile` or `useAuth` hook should be resolved before the auto-create mutation fires. Use TanStack Query's `enabled` flag to chain the dependency: only auto-create when both the budgets list AND the profile are loaded.
- In development, consider setting `debug: true` in `i18n.ts` — i18next logs `missingKey` warnings to console for every untranslated key in the non-fallback language. - Add an assertion in the mutation: if `currency` is undefined, throw rather than silently default.
- Use a TypeScript-typed i18n setup (e.g., `i18next-typescript`) so that `t("dashboard.nonExistentKey")` produces a type error at compile time.
- Before marking any phase complete, manually switch locale to German and click through every changed screen.
**Warning signs:** **Warning signs:**
- German UI text contains raw dot-notation strings (e.g., `dashboard.barChart.title`) - Auto-created budgets always show EUR symbol even when user has set USD in Settings
- `console.warn` messages containing `i18next::translator: missingKey de` - The profile `currency` is not read anywhere in the auto-create code path
- `de.json` has fewer top-level keys than `en.json` after a phase's changes - Test passes for EUR users only; USD/GBP users never tested
**Phase to address:** Every phase that adds new UI text; establish the key-parity check process in the first phase of the overhaul **Phase to address:** Auto-budget creation phase — the profile dependency must be resolved as part of the auto-create implementation, not as a follow-up fix.
--- ---
### Pitfall 6: Design Inconsistency Across Page Refreshes (The "Island Redesign" Problem) ### Pitfall 6: Wizard Creates Categories and Template Items Without Idempotency — Re-run Corrupts Data
**What goes wrong:** **What goes wrong:**
The overhaul covers all pages, but if phases are structured page-by-page, early pages get the richest design attention and later pages get inconsistency or fatigue-driven shortcuts. The most common failure mode: the dashboard uses a specific card style (colored header accent, icon in corner), but the Categories page — redesigned two weeks later — uses a subtly different card variant. Buttons on the Budget Detail page use different spacing than on the Template page. The result is a design that looks cohesive in screenshots of individual pages but feels broken when navigating between them. The setup wizard pre-fills common items (rent, groceries, car insurance, etc.) and on completion creates: (a) category rows in `categories`, (b) template_items in `template_items`. If the user runs the wizard, quits mid-way, then re-runs it, or if the wizard's "complete" step fires twice due to a network retry, duplicate categories and template items are created. The `categories` table has no unique constraint on `(user_id, name)`. The user ends up with "Rent" appearing twice as a category and twice as a template item.
**Why it happens:** **Why it happens:**
There is no design system enforced at the component level. shadcn/ui components are used directly in pages without project-specific wrappers. When the overhaul introduces new visual patterns (e.g., a colored icon badge, a section divider style), they are implemented inline in the first page that needs them and then drift in subsequent pages as developers make minor "feels close enough" adaptations. Wizard completion is typically a single "save all" mutation. If the user navigates back to step 1 and completes again, or if a network error causes a retry, the entire creation sequence runs again. Category creation has no deduplication.
**How to avoid:** **How to avoid:**
- Before redesigning any pages, establish a small shared component library of new visual primitives (e.g., `<SectionHeader>`, `<StatCard>`, `<CategoryBadge>`) with fixed prop interfaces. All page redesigns consume these components — they never re-implement them inline. - Add a database unique constraint on `categories (user_id, name)` (or at minimum check for existence before insert). Use upsert for category creation in the wizard flow.
- Define the full color palette and spacing scale in the first phase, not incrementally. The `index.css` `@theme` block is the single source of truth — no hardcoded hex values anywhere else. - Gate the wizard's final "create" step behind a `isFirstRun` flag stored in the user's `profiles` table (e.g., a `setup_completed` boolean). Once `true`, the wizard's create mutation is a no-op.
- Create a visual spec (even a quick screenshot grid) of what each page will look like before coding begins, so inconsistencies are caught at design time not code review time. - Use a two-phase completion approach: "Preview what will be created" → "Confirm" — this prevents accidental double-submission.
- In code review: any new Tailwind classes that don't use design tokens (e.g., `text-[#6b21a8]`, `p-[13px]`) should be flagged as violations. - If using localStorage for wizard state, clear it atomically when the server-side creation succeeds, not before.
**Warning signs:** **Warning signs:**
- Two pages use different visual patterns for "section with a list of items" (one uses a table, one uses cards) - Running the wizard twice results in duplicated category rows in the Categories page
- Color values appear as raw hex or oklch literals in component files instead of semantic tokens - Template page shows duplicate items with identical names
- shadcn Card component is used in 3 different ways across 3 pages (no wrapper abstraction) - A Supabase unique constraint error on `categories` fires if the constraint is added after the wizard is built
**Phase to address:** Design foundation phase (first phase of overhaul) before any page work begins **Phase to address:** Wizard setup phase — idempotency requirements must be specified as acceptance criteria before any wizard mutation code is written.
---
### Pitfall 7: CSS Variable Scope Rework Breaks Recharts SVG Fill Resolution
**What goes wrong:**
v1.0 established that category colors are passed to Recharts as CSS variable references resolved in JavaScript (`var(--color-income)` etc. via `categoryColors` in `palette.ts`). If the v2.0 design token rework renames these variables (e.g., from `--color-income` to `--category-income`, or changes the OKLCH values such that the Recharts SVG `fill` attributes show visually different colors than intended), charts break silently — the SVG renders but fills are wrong.
Additionally, Recharts SVG elements operate outside the standard CSS cascade for some browsers. If OKLCH values are changed to be very light (high L) in pursuit of "clearer pastels," SVG fills may become nearly invisible on white chart backgrounds because SVG doesn't inherit `background` opacity in the same way HTML elements do.
**Why it happens:**
Design token rework focuses on HTML components and Tailwind utility class output. Recharts SVG fills are wired through `palette.ts` as literal CSS variable strings — not Tailwind classes — so they are easy to miss in a token audit.
**How to avoid:**
- After any `index.css` token change, explicitly open the dashboard and inspect all three charts (donut, bar, horizontal bar) to verify fill colors match intention.
- Keep `palette.ts` as the single source of truth for chart colors — do not duplicate color definitions in component files.
- If renaming CSS variable names (not just changing values), grep the entire codebase for the old variable name before deleting it.
- Test chart fill visibility against the new `--background` value — lighter backgrounds require slightly more saturated or darker chart fills to maintain visibility.
**Warning signs:**
- Chart fills appear white or very faint after a design token change
- `palette.ts` still references `--color-income` after a rename to `--category-income`
- Charts look correct in Storybook/isolation but wrong on the actual dashboard
**Phase to address:** Design system foundation phase (when tokens change) AND dashboard phase (verification).
---
### Pitfall 8: "Template Empty" Edge Case Breaks Auto-Budget and New-User Wizard
**What goes wrong:**
The auto-budget creation flow depends on the template having items. The current `generateFromTemplate` code handles an empty template gracefully — it creates an empty budget. But the v2.0 UX intent is that visiting the dashboard for the first time should show a useful budget, not an empty one. If the wizard is bypassed (user skips setup), or if wizard completion fails silently, the auto-created budget has zero items, and the dashboard shows nothing meaningful.
A second edge case: a user who completed v1.0 setup manually (has categories + template items) visits v2.0 for the first time. The wizard should not re-run — but if the "has template items" check is the gate, and they had template items before, the wizard is skipped, and the auto-create fires correctly. If that check is wrong (e.g., checking `template.name === "My Monthly Template"` instead of `template_items.length > 0`), existing users get redirected into the wizard.
**Why it happens:**
The first-run detection logic is complex: it must distinguish between (a) brand new user with no data, (b) existing user who set up manually in v1.0, (c) user who started wizard but didn't finish, (d) user who completed wizard. Each case needs different behavior and the logic branches are easy to conflate.
**How to avoid:**
- Define the first-run gate explicitly using the `profiles.setup_completed` boolean (recommended in Pitfall 6) — this is the single authoritative signal, not inferred from data shape.
- For existing v1.0 users: write a migration or a one-time check that sets `setup_completed = true` for any user who already has template items. This prevents the wizard from showing for users who have already configured their template.
- Test all four user cases explicitly before shipping the wizard.
**Warning signs:**
- Existing users (who set up in v1.0) see the setup wizard on their next login
- New users who skip the wizard get a blank dashboard with no explanation
- The `setup_completed` flag is never set to `true` for existing users via migration
**Phase to address:** Wizard setup phase — the first-run gate and existing-user migration must be defined as part of the wizard's acceptance criteria, not as an afterthought.
--- ---
@@ -184,12 +204,13 @@ There is no design system enforced at the component level. shadcn/ui components
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | | Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------| |----------|-------------------|----------------|-----------------|
| Inline chart data transforms in render | Faster to write | Re-renders on every state change; adding more charts multiplies the cost | Never — always `useMemo` for chart data | | No `UNIQUE (user_id, start_date)` constraint on `budgets` — skip the migration | Saves one migration | Duplicate budgets on concurrent auto-create; data corruption that is painful to clean | Never — add the constraint before any auto-create code ships |
| Hardcoded color classes `text-green-600` for semantic states | Familiar Tailwind pattern | Dark mode variants need to be manually maintained in two places; fails contrast checks | Only for truly non-semantic colors (e.g., a green checkmark icon that is always green) | | Auto-create logic in a `useEffect` | Familiar React pattern | Double-invocation in Strict Mode, fires on re-render, race conditions | Never for write operations; use TanStack Query `useQuery` with `initialData` or a mutation gated on query result |
| Copying shadcn component patterns across pages without abstraction | Faster per-page development | Design drift guarantees inconsistency; changes to shared patterns require hunting all usages | MVP only, with explicit note to abstract before more pages are built | | Wizard state in component `useState` only | Simplest to write | Lost on refresh; user frustration on first run | Only for wizards that complete in under 30 seconds and have no network steps |
| Adding English i18n keys without German equivalent | Unblocks development | Silent regression in German locale; accumulates debt that is hard to audit later | Never in this project — add both languages in the same commit | | Skipping the `profiles.setup_completed` flag and inferring first-run from data shape | No migration needed | Complex conditional logic; wrong inference for edge-case users; breaks if data shape changes | Never — explicit flag is always more reliable than inferred state |
| Using `ResponsiveContainer` without `debounce` | Default behavior, no extra code | Multiple containers trigger simultaneous resize cascades when sections open/close | Acceptable for single-chart pages; set `debounce={50}` on all multi-chart layouts | | Removing `/quick-add` route without a redirect | One less route to maintain | Broken bookmarks, broken history, 404 for any hardcoded link | Never — always add a redirect when removing a route |
| Implementing collapsible with `display: none` toggle | Simplest implementation | No animation; abrupt layout shift; screen readers cannot detect intermediate states | Never for primary dashboard sections; acceptable for debug/dev-only UI | | CSS token rework and page component rework in the same PR | Ships faster | Impossible to attribute regressions; review is overwhelming; rollback is all-or-nothing | Never for design-system-wide token changes — tokens must land as their own commit |
| Using the `quick_add_items` table schema for the new category library | Reuses existing table and hooks | Conflates two different concepts; data model becomes unclear; future features referencing categories become harder | Only if category library is purely a UI concern with no persistence — but it needs persistence, so use categories table |
--- ---
@@ -197,11 +218,13 @@ There is no design system enforced at the component level. shadcn/ui components
| Integration | Common Mistake | Correct Approach | | Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------| |-------------|----------------|------------------|
| Recharts + Tailwind CSS variables | Passing Tailwind utility strings (e.g., `"text-blue-500"`) as chart `fill` or `stroke` props | Pass `var(--color-income)` or a resolved hex/oklch string via JS. Recharts SVG does not process Tailwind class strings. | | Supabase + auto-create budget | Plain INSERT in a client-side effect, no deduplication | Use INSERT ... ON CONFLICT DO NOTHING with a unique constraint on `(user_id, start_date)` |
| Recharts + `ResponsiveContainer` | Placing `ResponsiveContainer` inside a flex/grid parent without an explicit height | The container measures `0px` height. Always wrap in a `<div style={{ height: 240 }}>` or give the parent an explicit height class. | | TanStack Query + auto-create mutation | Firing mutation inside `useEffect` with data dependency | Use `useQuery` to check existence, then fire mutation via user action or a stable effect with ref guard |
| shadcn `Collapsible` + Recharts | Placing charts inside collapsible sections without `debounce` | When the collapsible opens, the chart container resizes and triggers `ResizeObserver` rapidly. Add `debounce={50}` to `ResponsiveContainer`. | | localStorage + wizard state | Storing state without user-scoping — one user's wizard state leaks to another on shared device | Key all localStorage entries with `${userId}` prefix |
| Tailwind v4 `@theme inline` + dark mode | Defining category colors only in `@theme inline` without a `.dark {}` override block | Category colors remain light-mode pastels on dark backgrounds. Define dark variants in a `.dark {}` block in `index.css`. | | Supabase RLS + category library pre-population | Inserting pre-filled categories from client-side wizard without user_id | `user_id` must be set to `auth.uid()` on all inserts; Supabase RLS policy will reject rows with wrong user_id |
| i18next + new UI sections | Adding `t("new.key")` JSX without adding the key to `de.json` | Silently falls back to English in German locale. Always update both files simultaneously. | | Recharts + new design tokens | Assuming token rename in `index.css` auto-updates SVG fill values | `palette.ts` references CSS variable names explicitly — any rename requires updating `palette.ts` too |
| React Router + wizard steps | Using browser back button to go "back" in wizard while step is tracked only in state | Use URL search params (`?step=2`) so browser back/forward navigate wizard steps correctly |
| TanStack Query + profile currency dependency | Auto-create fires before profile query resolves; uses default currency | Use `enabled: profileLoaded && budgetMissing` to chain query dependencies |
--- ---
@@ -209,11 +232,20 @@ There is no design system enforced at the component level. shadcn/ui components
| Trap | Symptoms | Prevention | When It Breaks | | Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------| |------|----------|------------|----------------|
| Unmemoized chart data on a multi-chart dashboard | Tooltip hover causes all three charts to re-render simultaneously; visible lag | `useMemo` for all data transforms, `useCallback` for formatter props, `React.memo` on chart sub-components | Noticeable with 50+ budget items; subtle with 1020 | | Wizard pre-populates 20+ categories in one bulk insert without optimistic UI | Wizard "complete" step hangs for 23 seconds; no feedback to user | Show a loading state immediately; use `mutate` with `onMutate` for optimistic feedback | Every user on wizard completion |
| O(n) category lookup per budget item across 3 charts | CPU spike when dashboard loads; slow initial paint | Build a `Map<id, Category>` once in the hook layer, O(1) lookup in components | Becomes visible at ~100 budget items | | Auto-create budget fires on every dashboard mount during loading state | Multiple budget rows created; duplicate toasts; TanStack Query cache stale | Gate auto-create on `budgets.isSuccess` (not `isLoading`), and use a ref to prevent repeat fires | Any fast page navigation that remounts the dashboard |
| Multiple `ResponsiveContainer` without `debounce` | Rapid resize loop on section toggle; "ResizeObserver loop" errors | Add `debounce={50}` to all `ResponsiveContainer` instances on the dashboard | Any time two or more containers are mounted simultaneously | | Category library rendered as a flat list with 50+ items and no virtualization | Picker feels slow and sluggish when inline in BudgetDetailPage | Virtualize the list with TanStack Virtual or paginate by category type | Not an issue at 10 items; noticeable at 30+; problematic at 50+ |
| Animating collapsible height with JavaScript | Dropped frames during expand/collapse; chart reflow cascade | Use Radix `Collapsible` with CSS `@keyframes` on `--radix-collapsible-content-height` | Every interaction on the dashboard | | Re-fetching template items on every wizard step navigation | Network calls on every back/forward in wizard; slow perceived performance | Cache template items in TanStack Query with a long `staleTime`; prefetch on wizard mount | Noticeable on slow connections even with 5 template items |
| TanStack Query unbounded cache growth | Long browser session consumes excessive memory | Set `gcTime` and `staleTime` on QueryClient config | After ~30 minutes of active use without page reload |
---
## Security Mistakes
| Mistake | Risk | Prevention |
|---------|------|------------|
| Wizard inserts categories without user_id from auth.uid() | Categories created with wrong or null user_id bypass RLS or are inaccessible | Always derive user_id from `supabase.auth.getUser()` inside the mutation; never pass it from client state |
| Setup wizard marks `setup_completed = true` client-side before DB write succeeds | User appears "set up" but has no data if the write fails; auto-create never triggers again | Mark `setup_completed = true` only in the mutation's `onSuccess` callback |
| Exposing wizard pre-fill library (category names + amounts) as a static client bundle | Not a critical risk for personal finance app, but hardcoded amounts (e.g., "Groceries: $500") are visible in source | Acceptable for this use case; note it is not PII |
--- ---
@@ -221,25 +253,27 @@ There is no design system enforced at the component level. shadcn/ui components
| Pitfall | User Impact | Better Approach | | Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------| |---------|-------------|-----------------|
| Color as the only signal for budget overrun (red progress bar) | Colorblind users cannot distinguish over-budget from on-track | Add an icon (exclamation mark) or text indicator alongside the color change | | Wizard with "skip" that results in empty dashboard | User skips setup, sees blank dashboard, doesn't know why nothing is there | If user skips, show a non-empty empty state with a clear "Set up your template" CTA |
| Hiding line items behind collapsible sections with no affordance | Users don't discover that sections are expandable; they think the dashboard is incomplete | Use an explicit chevron icon with visible state change; consider first-time-open hint | | Auto-created budget with no items (empty template) silently creates an empty budget | Dashboard appears to "work" but shows zeros; user confused why their categories aren't there | Check if template has items before auto-creating; if not, show a "complete your template setup" prompt |
| Chart tooltips showing raw numbers without currency formatting | Users see "1234.5" instead of "$1,234.50" — especially jarring in a financial app | Always pass `formatCurrency(value, currency)` in the Recharts `formatter` prop | | Removing quick-add nav item without explanation | Users who relied on quick-add can't find the feature; assume it's broken | Add a deprecation note or in-app transition message ("Quick-add is now available inline in your budget") |
| Summary cards showing totals without context (no comparison to last month or budget) | Numbers without context are harder to interpret; users don't know if $1,200 expenses is good or bad | Show budget vs actual delta or a "vs budget" sub-label on cards | | Wizard "back" button erases selections made in the later step | User goes back to change one thing and loses all subsequent work | Wizard back/forward must preserve all step state, not reset forward steps |
| Five-section collapsible dashboard that defaults to all-collapsed | Users land on a nearly empty dashboard and are confused | Default the primary sections (income, bills) to open on first load | | Auto-created budget uses wrong month if user's timezone differs from server | User visiting on Dec 31 in UTC+11 gets a January budget auto-created | Derive month from the client's local timezone, not the server's |
| Progress bar clamped to 100% without showing actual overage amount | Users cannot see how much they are over budget from the bar alone | Show the actual percentage (e.g., "132%") in the label even when the bar is clamped | | Category library in BudgetDetailPage shows all categories including types that contradict the budget context | Income categories shown when adding a one-off expense item; confusing | Filter the inline picker by relevant category types for the context (expense-only for one-off adds) |
--- ---
## "Looks Done But Isn't" Checklist ## "Looks Done But Isn't" Checklist
- [ ] **Charts:** Verify all three chart types (donut, bar, horizontal bar) render correctly when `items` is empty — Recharts renders a blank SVG that can overlap other content if dimensions are not handled - [ ] **Auto-create:** Verify that visiting the dashboard twice in quick succession (e.g., double-clicking the nav link) creates exactly one budget, not two — check the budgets list after each scenario
- [ ] **Dark mode:** Switch to dark theme (if implemented) and verify all category colors, chart fills, and semantic state colors (green/red) have sufficient contrast — light-mode testing only is the most common gap - [ ] **Auto-create currency:** Create a test account with currency set to USD in Settings, then visit the dashboard — verify the auto-created budget shows USD, not EUR
- [ ] **German locale:** Navigate every redesigned page in German and verify no raw key strings appear — `de.json` parity check - [ ] **Wizard re-run:** Complete the wizard on a fresh account, then manually navigate back to the wizard URL — verify it does not create duplicate categories
- [ ] **Collapsible sections:** Toggle each collapsible section open and closed rapidly three times — verify no layout shift in charts above/below, no "ResizeObserver loop" errors in console - [ ] **Existing user path:** Log in with an account that has v1.0 template items configured — verify the wizard does NOT appear, the existing template is preserved, and a budget is auto-created correctly
- [ ] **Empty states:** Load the dashboard with a budget that has zero actual amounts — verify charts handle `pieData.length === 0` and empty bar data without rendering broken empty SVGs - [ ] **Wizard refresh:** Start the wizard, reach step 3, refresh the browser — verify state is restored from localStorage and the wizard resumes at step 3
- [ ] **Long category names:** Enter a category named "Wiederkehrende monatliche Haushaltsausgaben" (long German string) — verify it doesn't overflow card boundaries or truncate without tooltip - [ ] **Quick-add redirect:** Navigate directly to `/quick-add` after the page is removed — verify a redirect to `/budgets` (or equivalent) occurs, not a blank page or 404
- [ ] **Currency formatting:** Verify EUR formatting (€1.234,56) and USD formatting ($1,234.56) both display correctly in chart tooltips and summary cards when locale is switched in Settings - [ ] **Empty template budget:** Remove all items from the template, then navigate to the dashboard — verify no crash, a meaningful empty state is shown, and no empty budget is silently created
- [ ] **Responsive at 1024px:** View the dashboard at exactly 1024px viewport width — the breakpoint where desktop layout switches — verify no horizontal overflow or chart sizing issues - [ ] **Token rework regression:** After changing `--radius` or category color tokens, navigate through all 9 pages (dashboard, categories, template, budget list, budget detail, settings, login, register, and any wizard page) — verify no visual regressions
- [ ] **German locale wizard:** Complete the entire wizard flow in German locale — verify no raw translation keys appear and all pre-filled item names are localized
- [ ] **Design consistency:** Navigate between the newly redesigned pages and the pages not yet touched in the current phase — verify no jarring visual inconsistencies at page transitions
--- ---
@@ -247,12 +281,12 @@ There is no design system enforced at the component level. shadcn/ui components
| Pitfall | Recovery Cost | Recovery Steps | | Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------| |---------|---------------|----------------|
| Chart re-render performance discovered in production | LOW | Add `useMemo` wrappers to chart data transforms; wrap chart components in `React.memo`; requires no visual changes | | Duplicate budgets from concurrent auto-create | MEDIUM | Write a cleanup query to find duplicate `(user_id, start_date)` pairs and delete the empty duplicates; add the unique constraint; patch the auto-create to use upsert |
| Color accessibility failures discovered post-launch | MEDIUM | Audit all semantic color uses with WCAG checker; update `index.css` CSS variable values; may require minor component changes for icon-supplement approach | | Wizard created duplicate categories | MEDIUM | Deduplicate categories by `(user_id, name)` — merge any budget_items or template_items pointing to the duplicate, then delete the duplicate category rows |
| i18n key regressions across multiple pages | MEDIUM | Run `i18next-scanner` to enumerate all missing German keys; systematically add translations; no code changes needed | | `setup_completed` flag never set for v1.0 users — wizard shows on login | LOW | Write a Supabase migration that sets `setup_completed = true` for any user with `template_items.count > 0`; deploy before releasing v2.0 |
| Design inconsistency across pages after all pages are shipped | HIGH | Requires extracting shared component abstractions retroactively and refactoring all pages — avoid by establishing components in first phase | | Token rework broke 3 pages discovered post-merge | MEDIUM | Revert the token commit if pages are too broken to patch quickly, or fix page-by-page with a visual regression pass; establish page-level snapshot tests |
| `height: 0 → auto` collapsible causing jank discovered mid-phase | LOW | Replace toggle logic with Radix `Collapsible` and add CSS keyframe animation — isolated to the collapsible component, no broader refactor needed | | Quick-add route removed without redirect — users hitting 404 | LOW | Add `<Route path="quick-add" element={<Navigate to="/budgets" replace />} />` — one-line fix; deploy immediately |
| `ResponsiveContainer` ResizeObserver loop in multi-chart layout | LOW | Add `debounce={50}` prop to each `ResponsiveContainer` — one-line fix per chart instance | | Auto-budget uses wrong currency for existing users | LOW | Add a one-time correction query: update budgets created since v2.0 launch that have EUR currency to the user's profile currency where they differ; patch the auto-create to read profile currency |
--- ---
@@ -260,36 +294,28 @@ There is no design system enforced at the component level. shadcn/ui components
| Pitfall | Prevention Phase | Verification | | Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------| |---------|------------------|--------------|
| Recharts re-render on every parent state change | Dashboard charts phase — add memoization before wiring data | React DevTools Profiler: chart sub-components should not appear in flame graph on tooltip hover | | Duplicate budget creation on concurrent auto-create | Auto-budget creation phase — add DB unique constraint before any auto-create code | Verify `(user_id, start_date)` unique constraint exists in migration; test double-navigation scenario |
| CSS variable scope / dark mode chart color gaps | Design tokens phase (first) | Inspect SVG `fill` in DevTools; toggle dark mode and visually verify all chart colors resolve | | Wizard state lost on refresh | Wizard setup phase — define localStorage persistence strategy in design | Refresh browser at step 3; verify wizard resumes at step 3 |
| Collapsible layout shift and ResizeObserver cascade | Dashboard collapsible sections phase | Toggle all sections rapidly; check console for ResizeObserver errors; check DevTools Performance for layout thrash | | Design token rework breaks existing pages | Design system foundation phase — token commit is isolated and followed by full-app visual pass | All 9 pages visually checked after every token change |
| Color accessibility failures | Design tokens phase — define accessible palette upfront | Run each color pair through WCAG contrast checker; use DevTools "Emulate vision deficiency" filter | | Quick-add removal breaks nav/bookmarks | Page consolidation phase — redirect added in same commit as route removal | Navigate to `/quick-add` directly; verify redirect |
| i18n key regressions | Every phase — enforce dual-language commit rule from the start | Run `i18next-scanner` before marking any phase complete; switch to German locale and navigate all changed pages | | Auto-budget ignores user currency | Auto-budget creation phase — profile dependency in auto-create spec | Test with USD-configured account; verify budget currency |
| Design inconsistency across page refreshes | Design foundation phase — create shared components before any page work | Code review: flag any Tailwind color/spacing classes that are not semantic tokens; check that all pages use shared `<SectionHeader>` / `<StatCard>` etc. | | Wizard creates duplicate categories on re-run | Wizard setup phase — idempotency specified as acceptance criteria | Run wizard twice; verify no duplicates in Categories page |
| CSS variable rename breaks Recharts fills | Design system foundation phase — grep for all variable references before rename | Open dashboard after any token rename; inspect all chart fills |
| Empty template edge case shows empty dashboard | Auto-budget + wizard phases — explicit first-run gate with `setup_completed` | Test "skip wizard" path; verify empty state shown, not silent empty budget |
| First-run gate triggers for existing v1.0 users | Wizard setup phase — migration to set `setup_completed` for users with existing template items | Log in with a v1.0 account; verify wizard is skipped |
--- ---
## Sources ## Sources
- [Recharts performance guide (official)](https://recharts.github.io/en-US/guide/performance/) - Direct codebase analysis: `src/hooks/useBudgets.ts` (generateFromTemplate), `src/hooks/useTemplate.ts` (getOrCreateTemplate), `supabase/migrations/004_budgets.sql`, `supabase/migrations/005_quick_add.sql`, `src/lib/types.ts`, `src/App.tsx`, `src/components/AppLayout.tsx`
- [Recharts deep-compare issue #281](https://github.com/recharts/recharts/issues/281) - TanStack Query mutation patterns: known double-invocation behavior in React 18 Strict Mode (`useEffect` firing twice)
- [Recharts ResizeObserver loop issue #1770](https://github.com/recharts/recharts/issues/1770) - Supabase upsert documentation: INSERT ... ON CONFLICT DO NOTHING / DO UPDATE pattern
- [Recharts ResponsiveContainer API](https://recharts.github.io/en-US/api/ResponsiveContainer/) - React Router v6 redirect patterns for deprecated routes
- [Improving Recharts performance (Belchior)](https://belchior.hashnode.dev/improving-recharts-performance-clp5w295y000b0ajq8hu6cnmm) - localStorage-based wizard state persistence: common pattern in onboarding flow implementations
- [Tailwind v4 dark mode docs](https://tailwindcss.com/docs/dark-mode) - PostgreSQL unique constraint behavior on concurrent inserts
- [shadcn/ui Tailwind v4 guide](https://ui.shadcn.com/docs/tailwind-v4)
- [Tailwind v4 migration breaking changes discussion](https://github.com/tailwindlabs/tailwindcss/discussions/16517)
- [shadcn theming with Tailwind v4 and CSS variables (Goins)](https://medium.com/@joseph.goins/theming-shadcn-with-tailwind-v4-and-css-variables-d602f6b3c258)
- [WCAG 2.1 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html)
- [WCAG SC 1.4.3 Contrast Minimum](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html)
- [WebAIM contrast checker](https://webaim.org/resources/contrastchecker/)
- [Radix UI Collapsible primitive](https://www.radix-ui.com/primitives/docs/components/collapsible)
- [i18next missing key detection discussion](https://github.com/i18next/i18next/discussions/2088)
- [Fintech dashboard design (Merge Rocks)](https://merge.rocks/blog/fintech-dashboard-design-or-how-to-make-data-look-pretty)
- [Dashboard UX design principles (Smashing Magazine)](https://www.smashingmagazine.com/2025/09/ux-strategies-real-time-dashboards/)
- Existing codebase analysis: `src/pages/DashboardPage.tsx`, `src/lib/palette.ts`, `src/index.css`, `.planning/codebase/CONCERNS.md`
--- ---
*Pitfalls research for: SimpleFinanceDash UI overhaul (React + Recharts + Tailwind v4 + shadcn/ui)* *Pitfalls research for: SimpleFinanceDash v2.0 — wizard setup, auto-budget creation, design system rework, page consolidation*
*Researched: 2026-03-16* *Researched: 2026-04-02*

View File

@@ -1,234 +1,277 @@
# Stack Research # Stack Research
**Domain:** Personal finance dashboard UI overhaul — React SPA **Domain:** Personal finance dashboard — v2.0 wizard setup, auto-budget creation, sharp pastel design system
**Researched:** 2026-03-16 **Researched:** 2026-04-02
**Confidence:** HIGH **Confidence:** HIGH
--- ---
## Context: What Already Exists ## Context: What Already Exists (Do Not Re-Research)
This is a subsequent-milestone research document. The stack is **locked**. The project uses: Actual installed versions from `package.json` (not the prior research document, which had aspirational versions):
| Package | Installed Version | Status | | Package | Actual Installed Version | Notes |
|---------|------------------|--------| |---------|-------------------------|-------|
| React | 19.2.4 | Locked | | React | ^19.2.4 | Locked |
| Vite | 8.0.0 | Locked | | Vite | ^8.0.0 | Locked |
| TypeScript | ~5.9.3 | Locked | | TypeScript | ~5.9.3 | Locked |
| Tailwind CSS | 4.2.1 | Locked | | Tailwind CSS | ^4.2.1 | Locked |
| Recharts | **3.8.0** | Locked — already on latest | | Recharts | **2.15.4** | Locked — NOT 3.x (prior research was wrong) |
| Radix UI (via `radix-ui`) | 1.4.3 | Locked | | radix-ui | ^1.4.3 | Unified post-June 2025 package |
| Lucide React | 0.577.0 | Locked | | lucide-react | ^0.577.0 | Locked |
| next-themes | 0.4.6 | Locked | | next-themes | ^0.4.6 | Locked |
| TanStack Query | 5.90.21 | Locked | | TanStack Query | ^5.90.21 | Locked |
| react-router-dom | ^7.13.1 | Locked |
| sonner | ^2.0.7 | Locked |
| @supabase/supabase-js | ^2.99.1 | Backend is Supabase, not custom Go |
| i18next + react-i18next | ^25.8.18 / ^16.5.8 | Locked |
The `index.css` already defines a complete OKLCH `@theme inline` color system with category-specific colors (`--color-income`, `--color-bill`, etc.) and chart colors (`--color-chart-1` through `--color-chart-5`). The project uses the `radix-ui` unified package (post-June 2025 migration). **Backend reality check:** Despite the milestone context referencing "Go 1.25 backend", the project is a Supabase-backed React SPA. There is no Go source code. All backend work happens via Supabase migrations (SQL) and the `@supabase/supabase-js` client. This is authoritative — verified from `package.json` and `supabase/migrations/`.
**No new frameworks or backend dependencies.** This research covers only what to add or lean into for the UI overhaul. **Form handling reality check:** Current forms (login, register, template items) use vanilla `useState` + `React.FormEvent`. No react-hook-form or zod are installed.
**Design token reality check:** `--radius: 0.625rem` is applied globally and is the source of the "clunky rounded" look. Sharp redesign requires overriding this token.
--- ---
## Recommended Stack: Additions and Patterns ## New Dependencies Required
### shadcn/ui Components to Add ### 1. react-hook-form + zod — Wizard Form Validation
The project has shadcn/ui components already (card, button, input, etc.) but is missing two critical primitives for the redesign. The wizard-style template setup requires multi-step form state that persists across steps, per-step validation before advancing, and type-safe schema enforcement. Vanilla `useState` breaks down across 4+ wizard steps with interdependent validation.
| Component | Install Command | Purpose | Why | **Use react-hook-form 7.72.0 + zod 4.3.6 + @hookform/resolvers 5.2.2.**
|-----------|----------------|---------|-----|
| `chart` | `npx shadcn@latest add chart` | `ChartContainer` + `ChartTooltipContent` wrappers | Provides theme-aware chart wrappers that read from CSS variables. Eliminates boilerplate for consistent chart theming across all chart types. Copy-paste into `src/components/ui/chart.tsx`. |
| `accordion` | `npx shadcn@latest add accordion` | Collapsible category sections on dashboard | Built on Radix UI Accordion primitive. WAI-ARIA compliant, keyboard navigable, supports `type="multiple"` to keep multiple groups open. |
| `collapsible` | `npx shadcn@latest add collapsible` | Single collapsible toggle pattern | Alternative to Accordion when only one independent section needs to expand/collapse (simpler than full Accordion). |
**CRITICAL: Recharts v3 + shadcn chart.tsx compatibility.** The project is on Recharts 3.8.0. The official shadcn/ui chart PR (#8486) for Recharts v3 support is not yet merged (as of March 2026). After running `npx shadcn@latest add chart`, the generated `chart.tsx` may need manual fixes: | Package | Version | Purpose | Why |
|---------|---------|---------|-----|
| `react-hook-form` | 7.72.0 | Multi-step form state, per-field validation | Industry standard for React forms; tiny bundle (9.3kb); uncontrolled inputs avoid re-render storms across wizard steps |
| `zod` | 4.3.6 | Schema validation with TypeScript inference | Zod 4 is stable (released 2025, latest 4.3.6); 14x faster than v3; subpath exports `zod/v4` enable coexistence if needed |
| `@hookform/resolvers` | 5.2.2 | Bridge between zod schemas and react-hook-form | v5.2.2 supports both Zod v3 and v4 with automatic runtime detection |
1. The `ChartContainer` may log `"width(-1) and height(-1) of chart should be greater than 0"` warnings — fix by adding `initialDimension={{ width: 320, height: 200 }}` to the `ResponsiveContainer` inside `chart.tsx`. **Zod v4 import note:** Use `import { z } from "zod"` (root export maps to v4) unless you need v3 compatibility, then use `import { z } from "zod/v4"` explicitly. The `zodResolver` from `@hookform/resolvers/zod` handles both transparently.
2. If TypeScript errors appear on `TooltipProps`, update the import to use `TooltipProps` from `recharts` directly.
3. Community-verified workaround exists in shadcn-ui/ui issue #9892.
**Confidence: HIGH** — PR #8486 and issue #9892 are the authoritative sources; the fix is a small patch to one file. **Known issue:** @hookform/resolvers 5.2.1+ has reported edge-case type errors with specific Zod v4 configurations (GitHub issue #813). Use `5.2.2` (published ~February 2026) which addresses this. If type errors appear, verify the import path is `@hookform/resolvers/zod` not a sub-subpath.
**Confidence: HIGH** — Versions verified via npm search results (react-hook-form 7.72.0 published April 2026; zod 4.3.6; @hookform/resolvers 5.2.2). Integration pattern is the standard shadcn/ui documented approach.
--- ---
### Core Charting Patterns (Recharts 3.8.0) ### 2. Wizard/Stepper UI — Custom Component (No External Library)
No new charting libraries are needed. Recharts 3.8.0 is the current stable release and supports all required chart types natively. Three chart types are needed for the dashboard redesign: shadcn/ui does not include a stepper or wizard component in its official core library. The discussion thread #1422 requesting it has been open for years. However, shadcn/ui's blocks library now includes stepper patterns (as of March 2026 update) built purely with shadcn/ui `Button`, `Badge`, and Tailwind — **no external npm package needed**.
**1. Donut Chart (expense category breakdown)** **Recommendation: Build a custom `<WizardStepper>` component from shadcn/ui primitives.**
This approach:
- Requires zero new npm packages
- Matches the existing design system exactly (token-driven styling)
- Is 5080 lines of TypeScript — not complex
- Avoids third-party stepper library churn (none are dominant; most are abandoned within 1-2 years)
Pattern:
```tsx ```tsx
// Already in use. Enrich by: // src/components/shared/WizardStepper.tsx
// - Increasing innerRadius to ~60, outerRadius to ~100 for visual weight // Steps indicator: array of step objects → renders numbered circles + connecting lines
// - Adding paddingAngle={3} for visible segment separation // Active step gets primary color; completed steps get checkmark icon
// - Using stroke="none" (replaces removed blendStroke in v3) // Controlled via `currentStep: number` prop passed down from wizard page
// - Rendering a center label via custom activeShape or absolute-positioned div
<PieChart>
<Pie
data={pieData}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={3}
stroke="none"
>
{pieData.map((entry) => (
<Cell key={entry.type} fill={categoryColors[entry.type]} />
))}
</Pie>
<Tooltip content={<ChartTooltipContent />} />
</PieChart>
```
Note: In Recharts v3, `Cell` is deprecated in favor of the `shape` prop and `activeShape`/`inactiveShape` are deprecated in favor of `shape`. Existing `Cell` usage still works; migration is not required for this milestone. interface Step {
id: string
label: string
}
**2. Vertical Bar Chart (income budget vs actual)** interface WizardStepperProps {
steps: Step[]
```tsx currentStep: number // 0-based index
<BarChart data={incomeData}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Bar dataKey="budgeted" radius={[4, 4, 0, 0]} fill="var(--color-muted)" />
<Bar dataKey="actual" radius={[4, 4, 0, 0]} fill="var(--color-income)" />
<Tooltip content={<ChartTooltipContent />} />
</BarChart>
```
**3. Horizontal Bar Chart (spend by category type)**
```tsx
// layout="vertical" turns BarChart into a horizontal bar chart
<BarChart data={categoryData} layout="vertical">
<XAxis type="number" hide />
<YAxis type="category" dataKey="name" width={130} />
<Bar dataKey="budgeted" radius={[0, 4, 4, 0]} fill="var(--color-muted)" />
<Bar dataKey="actual" radius={[0, 4, 4, 0]} fill="var(--color-primary)" />
<Tooltip content={<ChartTooltipContent />} />
</BarChart>
```
**Confidence: HIGH** — All three patterns verified against Recharts official examples and docs.
---
### Tailwind CSS 4 Color System Patterns
The existing `index.css` is already correctly structured with `@theme inline`. The overhaul needs to extend it with:
**1. Richer category colors (more vibrant for charts)**
The current category colors are defined but conservative. For the rich visual style target, increase chroma:
```css
/* Recommended: bump chroma from ~0.14 to ~0.18+ for visual richness */
--color-income: oklch(0.68 0.19 145); /* vivid green */
--color-bill: oklch(0.65 0.19 25); /* vivid orange-red */
--color-variable-expense: oklch(0.70 0.18 55); /* vivid amber */
--color-debt: oklch(0.62 0.20 355); /* vivid rose */
--color-saving: oklch(0.68 0.18 220); /* vivid blue */
--color-investment: oklch(0.65 0.18 285); /* vivid purple */
```
**2. Semantic status tokens for budget comparison**
```css
--color-over-budget: oklch(0.62 0.20 25); /* red-orange for overspend */
--color-on-budget: oklch(0.68 0.19 145); /* green for on-track */
--color-budget-bar-bg: oklch(0.92 0.01 260); /* neutral track for progress bars */
```
**3. Dark theme token set**
The current CSS has no dark mode overrides. With `next-themes` v0.4.6 already installed, dark mode works by toggling `.dark` on `<html>`. Add a dark block:
```css
.dark {
@theme inline {
--color-background: oklch(0.13 0.02 260);
--color-foreground: oklch(0.95 0.005 260);
--color-card: oklch(0.18 0.02 260);
/* ...etc — mirror the light tokens with dark values */
}
} }
``` ```
This is optional for the current milestone (desktop-first, dark mode not listed as a goal), but the token structure is already set up for it. State management: hold `currentStep` in the wizard page component with `useState`. Each step's validation runs via `react-hook-form`'s `trigger(fieldNames)` before `setCurrentStep(n + 1)`.
**Confidence: HIGH**Tailwind CSS 4 `@theme inline` pattern verified against official Tailwind v4 docs. **Confidence: HIGH**shadcn/ui blocks page confirms stepper patterns are available as copy-paste blocks with no external dependencies (verified March 2026). Zero external library is the correct choice.
--- ---
### Typography Patterns ### 3. shadcn/ui Components to Add
Already uses Inter via `--font-sans`. No new font installations needed. Apply these patterns consistently: The wizard needs two additional shadcn/ui components not currently installed:
| Pattern | CSS / Tailwind | Where to Use | | Component | Install Command | Purpose | Notes |
|---------|---------------|--------------| |-----------|----------------|---------|-------|
| Tabular numbers | `tabular-nums` (already used in DashboardPage) | All currency amounts, percentages | | `progress` | `npx shadcn@latest add progress` | Optional step progress bar within wizard header | Built on Radix UI Progress; already themed via CSS vars. Use if linear progress indicator preferred over dot/circle steps. |
| Monospace amounts | `font-mono` | Dense data tables where column alignment matters | | `checkbox` | `npx shadcn@latest add checkbox` | Select/deselect template items in wizard step | Built on Radix UI Checkbox; WAI-ARIA compliant. |
| Numeric font features | `font-feature-settings: "tnum" 1` | When `tabular-nums` alone is insufficient | | `scroll-area` | `npx shadcn@latest add scroll-area` | Scrollable category library panel in budget view | Built on Radix UI ScrollArea; consistent cross-browser scrollbar styling. |
| Large metric emphasis | `text-3xl font-bold tracking-tight` | Summary card primary values |
| Muted labels | `text-sm text-muted-foreground` | Section headers, stat labels |
The codebase already uses `tabular-nums` on currency values — this is correct and should be applied everywhere financial numbers appear. The existing `dialog.tsx`, `input.tsx`, `button.tsx`, `select.tsx`, `badge.tsx`, and `skeleton.tsx` are already present and sufficient for the wizard steps and inline library picker.
**Confidence: HIGH**Pattern matches current codebase and Shopify Polaris / Datawrapper typography standards. **Confidence: HIGH**Verified against current component list in `src/components/ui/`.
--- ---
### Layout Patterns ## Design Token Changes — Sharp Minimal Pastel System
**Summary card grid:** No new packages are needed. All changes are CSS variable overrides in `src/index.css`.
```tsx ### Border Radius — The Core Change
// 4 cards on desktop, 2x2 on tablet, stacked on mobile
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> The v1.0 design feels "clunky and rounded" because `--radius: 0.625rem` (10px) is applied everywhere. The v2.0 "sharp minimal" direction requires:
```css
/* Sharp edges — change ONE token, affects all shadcn/ui components */
--radius: 0.125rem; /* 2px — sharp but not perfectly square */
/* Alternative: 0rem for fully square (more aggressive) */
``` ```
**Chart row:** This single change propagates through every shadcn/ui component because they all use `rounded-[var(--radius)]` or similar. No individual component changes needed.
```tsx ### Pastel Color Recalibration
// Charts side-by-side on desktop, stacked on mobile
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> The current category colors use L~0.55 (text-weight, too dark for fills and backgrounds). "Clear pastels" in a sharp minimal design system need a two-tier system:
- **Surface pastel** (card backgrounds, highlights): L~0.93-0.96, C~0.04-0.06
- **Accent pastel** (borders, tags, icons): L~0.75-0.80, C~0.10-0.12
- **Text** (labels, amounts): L~0.40-0.50, C~0.15-0.18 (existing values are correct for this)
```css
/* New: pastel surface colors for wizard step indicators, category cards */
--color-income-surface: oklch(0.95 0.04 155);
--color-bill-surface: oklch(0.95 0.04 25);
--color-variable-expense-surface: oklch(0.95 0.04 50);
--color-debt-surface: oklch(0.95 0.05 355);
--color-saving-surface: oklch(0.95 0.04 220);
--color-investment-surface: oklch(0.95 0.04 285);
/* New: accent pastel (for badges, step indicators, borders) */
--color-income-accent: oklch(0.80 0.10 155);
--color-bill-accent: oklch(0.80 0.10 25);
--color-variable-expense-accent: oklch(0.80 0.10 50);
--color-debt-accent: oklch(0.80 0.11 355);
--color-saving-accent: oklch(0.80 0.10 220);
--color-investment-accent: oklch(0.80 0.10 285);
``` ```
**Collapsible category section:** The existing text-weight category colors (`--color-income`, etc.) remain unchanged — they're already correct for text/icon usage and pass WCAG 4.5:1.
```tsx ### Minimal Layout Tokens
// Using shadcn Accordion — one group per CategoryType
<Accordion type="multiple" defaultValue={["income", "bill"]}> ```css
<AccordionItem value="income"> /* Reduce visual weight for card borders — sharp design reads better with subtle borders */
<AccordionTrigger> --color-border: oklch(0.90 0.008 260); /* slightly lighter than current 0.88 */
{/* Summary row: label + budgeted + actual + delta */}
</AccordionTrigger> /* Tighter card padding via CSS custom property (used in PageShell / StatCard) */
<AccordionContent> /* No token needed — use Tailwind p-4 consistently instead of p-6 */
{/* Line item rows */}
</AccordionContent>
</AccordionItem>
</Accordion>
``` ```
Use `type="multiple"` so users can expand multiple sections simultaneously. Default expand income and variable_expense as the most-checked categories. **Confidence: HIGH** — Based on OKLCH perceptual uniformity properties; the lightness/chroma values follow the Evil Martians OKLCH guide for pastel palette design.
**Confidence: HIGH** — Standard shadcn/ui pattern, verified against official docs.
--- ---
## What NOT to Use ## Backend Changes — Supabase (Auto-Budget Creation + Template Seeding)
### Auto-Budget Creation
The feature "auto-create monthly budgets from template on first visit" requires:
1. **A Supabase RPC function** (PostgreSQL function called via `.rpc()`) to atomically create a budget + copy template items in a single round-trip. Application-layer logic (JavaScript calling multiple inserts) is fragile across network failures.
```sql
-- New migration: create_budget_from_template(user_id, month_date)
-- Returns the new budget_id
-- Idempotent: if budget for that month already exists, returns existing id
create or replace function create_budget_from_template(
p_user_id uuid,
p_month date -- first day of the target month
)
returns uuid
language plpgsql
security definer -- needed to bypass RLS for the INSERT
as $$
declare
v_template_id uuid;
v_budget_id uuid;
begin
-- Get user's template
select id into v_template_id from templates where user_id = p_user_id limit 1;
if v_template_id is null then return null; end if;
-- Idempotency: return existing budget for this month
select id into v_budget_id
from budgets
where user_id = p_user_id
and start_date = date_trunc('month', p_month)::date
limit 1;
if v_budget_id is not null then return v_budget_id; end if;
-- Create new budget
insert into budgets (user_id, start_date, end_date, currency)
values (
p_user_id,
date_trunc('month', p_month)::date,
(date_trunc('month', p_month) + interval '1 month' - interval '1 day')::date,
(select currency from profiles where id = p_user_id limit 1)
)
returning id into v_budget_id;
-- Copy template items to budget items
insert into budget_items (budget_id, category_id, budgeted_amount, item_tier)
select v_budget_id, ti.category_id, ti.budgeted_amount, ti.item_tier
from template_items ti
where ti.template_id = v_template_id;
return v_budget_id;
end;
$$;
```
Frontend call:
```typescript
const { data: budgetId } = await supabase.rpc('create_budget_from_template', {
p_user_id: user.id,
p_month: `${year}-${month}-01`
})
```
**Why RPC not application layer:** A single RPC is atomic (no partial state if network drops mid-sequence), runs in 1 round-trip instead of 3-4, and the idempotency guard prevents duplicate budgets from double-calls (React StrictMode double-invocation, fast navigation, etc.).
### Template Seeding (Wizard First-Run)
The wizard "pre-filled common items" require a static list seeded client-side — no backend change needed. The wizard presents checkboxes for ~15 common items (Rent, Salary, Car Insurance, Groceries, etc.) with pre-populated amounts. On wizard completion, one batch `insert` creates the template + template_items. The existing `templates` and `template_items` tables support this without schema changes.
**Categories pre-seeding:** Categories (income, bill, variable_expense, etc.) are per-user, not system-wide. The wizard must also insert default categories before template items can reference them. Order: insert default categories → insert template → insert template_items.
This can also be an RPC (`setup_user_template`) or application-layer batch — the wizard runs once, so network failure risk is acceptable at client-side orchestration.
### Category Library (Inline Add-from-Library)
The `quick_add_items` table stores name + icon only — no amount or category_type. For the inline category library in budget view (replacing the QuickAdd page), the library needs category_type to know which budget section to add to.
**Schema addition needed:**
```sql
-- New migration: add category_type to quick_add_items
alter table quick_add_items
add column category_type category_type, -- existing enum
add column default_amount numeric(12,2) default 0;
```
This enables the library to show items grouped by type and pre-fill amounts when adding to a budget.
**Confidence: MEDIUM** — RPC pattern is well-established in Supabase. Schema addition is straightforward. The exact RPC SQL above is a starting point and may need adjustment based on profiles table structure (currency field). Flag for verification in planning phase.
---
## What NOT to Add
| Avoid | Why | Use Instead | | Avoid | Why | Use Instead |
|-------|-----|-------------| |-------|-----|-------------|
| Nivo | Adds a large bundle, different component API, requires adapting all existing chart code | Recharts 3.8.0 — already installed, sufficient | | External stepper library (react-use-wizard, react-step-wizard, stepperize) | None is dominant; all add bundle weight for what is 50 lines of custom code; APIs change | Custom `<WizardStepper>` using shadcn/ui Button + Badge + Tailwind |
| ApexCharts / ECharts | New library dependency, no benefit over Recharts for this scope | Recharts 3.8.0 | | Zod v3 (avoid if installing fresh) | v4 is stable and 14x faster; no reason to use v3 for new code | `zod@4.3.6` |
| react-financial-charts | Designed for candlestick / OHLC trading charts, not budget dashboards | Recharts 3.8.0 | | Framer Motion for wizard step transitions | 28kb gzip; CSS `transition-opacity duration-200` is sufficient for step fade | Tailwind transition utilities |
| Recharts 2.x | Project is already on v3.8.0 — never downgrade | Stay on 3.8.0 | | Recharts upgrade to 3.x in this milestone | 2.15.4 is working; v3 migration guide lists breaking changes that would require updating all existing charts; out of scope | Stay on 2.15.4 for v2.0 milestone; plan upgrade separately |
| shadcn `npx shadcn@latest add` for chart without reading the output | The generated chart.tsx requires a manual Recharts v3 patch | Run the add command, then apply the `initialDimension` fix to `ChartContainer` | | CSS-in-JS for new design tokens | Tailwind v4 `@theme inline` in `index.css` already handles all theming | Extend `index.css` `@theme inline` block |
| CSS-in-JS for theming | Tailwind v4 `@theme inline` already handles all theming via CSS variables | Extend `index.css` `@theme inline` block | | Zustand for wizard state | TanStack Query + useState is already the state pattern in this app; wizard state is local and short-lived | `useState` in wizard page component |
| Custom progress bar libraries | Raw Tailwind divs with `style={{ width: X% }}` are already used and are sufficient | Keep existing progress bar pattern, improve styling only | | Supabase Edge Functions for auto-budget | PostgreSQL function (RPC) runs in the same DB transaction; simpler, no Deno runtime | Supabase RPC (pg function) |
| Framer Motion | Adds bundle weight; CSS transitions via `transition-all duration-200` cover needed animations | Tailwind transition utilities |
| React Grid Layout | Drag/resize dashboards are out of scope for this milestone | Standard CSS Grid |
--- ---
@@ -236,10 +279,26 @@ Use `type="multiple"` so users can expand multiple sections simultaneously. Defa
| Recommended | Alternative | When to Use Alternative | | Recommended | Alternative | When to Use Alternative |
|-------------|-------------|-------------------------| |-------------|-------------|-------------------------|
| Recharts 3.8.0 (stay) | Nivo | If you needed animated, visually opinionated charts with server-side rendering — not applicable here | | Custom WizardStepper | `react-use-wizard` (npm) | If wizard logic were complex (branching paths, async steps with external dependencies) — this wizard is linear with simple validation |
| shadcn Accordion | KendoReact PanelBar | If the project used KendoReact's broader component suite — it doesn't | | Zod 4 | Valibot | If bundle size were critical (Valibot is ~1kb vs Zod's ~14kb) — not a concern here |
| Tailwind `tabular-nums` | Geist Mono font | Use Geist Mono only if building a dense trading-style table where column alignment is a core feature, not a budget dashboard | | Supabase RPC for auto-budget | Application-layer multi-insert | Acceptable if you tolerate partial failure risk and handle retry logic — RPC is cleaner |
| shadcn chart.tsx (copy-paste) | Direct Recharts usage | Acceptable — the project's existing dashboard already uses Recharts directly. shadcn wrappers add theme-aware tooltips and config-based colors but are not mandatory | | Two-tier OKLCH pastel tokens | Single token set | If the design only needed chart colors — the wizard and library UI need surface pastels that the existing single-tier system doesn't provide |
---
## Installation
```bash
# Wizard form validation (new packages)
bun add react-hook-form@7.72.0 zod@4.3.6 @hookform/resolvers@5.2.2
# New shadcn/ui components
npx shadcn@latest add progress
npx shadcn@latest add checkbox
npx shadcn@latest add scroll-area
```
No other npm/bun installs needed. All other new functionality (wizard stepper UI, design token changes, Supabase RPC, schema migration) is implemented via local code and SQL.
--- ---
@@ -247,43 +306,59 @@ Use `type="multiple"` so users can expand multiple sections simultaneously. Defa
| Package | Version | Compatible With | Notes | | Package | Version | Compatible With | Notes |
|---------|---------|-----------------|-------| |---------|---------|-----------------|-------|
| Recharts | 3.8.0 | React 19 | Requires `react-is` peer dependency override for React 19 | | react-hook-form | 7.72.0 | React 19 | Fully compatible; hooks-based, no legacy API |
| shadcn chart.tsx | CLI generated | Recharts 3.8.0 | Apply `initialDimension` fix to `ChartContainer` after generation | | zod | 4.3.6 | TypeScript ~5.9.3 | Requires TypeScript 4.5+; project is on 5.9.3 — fine |
| Tailwind CSS | 4.2.1 | `@theme inline` | Use `@theme inline` (not bare `@theme`) when referencing other CSS vars | | @hookform/resolvers | 5.2.2 | react-hook-form 7.x + zod 4.x | Automatic v3/v4 Zod detection |
| next-themes | 0.4.6 | React 19 | Compatible; dark mode toggled via `.dark` class on `<html>` | | Recharts | 2.15.4 | React 19 | Stays on v2; DO NOT upgrade in this milestone |
| radix-ui | 1.4.3 | Post-June 2025 migration | shadcn CLI add commands now generate imports from `radix-ui`, not `@radix-ui/react-*` | | Tailwind CSS | 4.2.1 | `@theme inline` | Sharp radius via `--radius: 0.125rem` in `@theme inline` block |
| radix-ui | 1.4.3 | shadcn new-york style | New shadcn add commands generate `radix-ui` imports (unified package), not `@radix-ui/*` |
--- ---
## Installation ## Integration Points
```bash ### Wizard → Template → Budget Flow
# Add shadcn chart primitives (then apply Recharts v3 fix to chart.tsx)
npx shadcn@latest add chart
# Add Accordion for collapsible dashboard sections ```
npx shadcn@latest add accordion WizardPage (new route: /setup)
└─ react-hook-form (FormProvider wrapping all steps)
└─ WizardStepper component (step indicator)
└─ Step 1: Pick categories (checkbox + default amounts)
└─ Step 2: Adjust fixed items (input fields, pre-filled)
└─ Step 3: Adjust variable items
└─ Step 4: Review + confirm
└─ onSubmit → batch insert categories + template + template_items
└─ redirect to /dashboard
# Add Collapsible if single-section toggles are needed DashboardPage (existing)
npx shadcn@latest add collapsible └─ on mount: check if budget exists for current month
└─ if not: call supabase.rpc('create_budget_from_template', { month })
└─ render budget data (existing pattern, improved display)
BudgetDetailPage (existing, enhanced)
└─ inline category library panel (uses scroll-area + checkbox/button)
└─ add one-off items directly from library
``` ```
No npm/bun package installs needed — Recharts 3.8.0 is already installed and sufficient. ### Design Token Change Impact
The `--radius` change in `index.css` requires no component-level changes — all shadcn/ui components use the token automatically. The new surface/accent OKLCH tokens need to be applied explicitly in new components (wizard steps, category library cards); existing components are unaffected.
--- ---
## Sources ## Sources
- [Recharts GitHub Releases](https://github.com/recharts/recharts/releases) — version 3.8.0 confirmed latest stable (March 2025) - npm search results (April 2026) — react-hook-form 7.72.0, zod 4.3.6, @hookform/resolvers 5.2.2
- [Recharts 3.0 Migration Guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide) — breaking changes from v2 to v3 - [zod.dev/v4](https://zod.dev/v4) — Zod 4 stable release notes, versioning strategy
- [shadcn/ui Chart Docs](https://ui.shadcn.com/docs/components/chart) — ChartContainer, ChartConfig, ChartTooltip patterns - [shadcn/ui blocks page](https://ui.shadcn.com/blocks) — Confirmed no native stepper component; patterns available as copy-paste blocks (no npm dep)
- [shadcn-ui/ui PR #8486](https://github.com/shadcn-ui/ui/pull/8486) — Recharts v3 chart.tsx upgrade (open as of March 2026) - [shadcn-ui/ui Discussion #1422](https://github.com/shadcn-ui/ui/discussions/1422) — Feature request for stepper (still open as of April 2026)
- [shadcn-ui/ui Issue #9892](https://github.com/shadcn-ui/ui/issues/9892) — Community-verified Recharts v3 chart.tsx fix - [react-hook-form/resolvers GitHub](https://github.com/react-hook-form/resolvers/releases) — v5.2.2 Zod v4 support
- [shadcn/ui Accordion Docs](https://ui.shadcn.com/docs/components/radix/accordion) — component API verified - [github.com/recharts/recharts/wiki/3.0-migration-guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide) — v3 breaking changes confirming upgrade is out of scope for this milestone
- [Tailwind CSS v4 Theme Docs](https://tailwindcss.com/docs/theme) — `@theme inline`, CSS variables, OKLCH palette - [evilmartians.com — Better dynamic themes with OKLCH](https://evilmartians.com/chronicles/better-dynamic-themes-in-tailwind-with-oklch-color-magic) — OKLCH lightness/chroma guidance for pastel systems
- [npm recharts](https://www.npmjs.com/package/recharts) — 3.8.0 current, 7M weekly downloads, healthy maintenance - `package.json` in project root — authoritative source for actual installed versions (Recharts 2.15.4, not 3.x as prior research stated)
- `supabase/migrations/` — authoritative source confirming Supabase backend, existing schema
--- ---
*Stack research for: SimpleFinanceDash UI Overhaul* *Stack research for: SimpleFinanceDash v2.0 — Wizard Setup, Auto-Budget, Sharp Pastel Design*
*Researched: 2026-03-16* *Researched: 2026-04-02*

View File

@@ -1,200 +1,183 @@
# Project Research Summary # Project Research Summary
**Project:** SimpleFinanceDash — UI/UX Overhaul Milestone **Project:** SimpleFinanceDash v2.0 — Wizard Setup, Auto-Budget Creation, Sharp Pastel Design
**Domain:** Personal finance budget dashboard (React SPA, UI-only redesign) **Domain:** Personal finance dashboard React SPA with Supabase backend
**Researched:** 2026-03-16 **Researched:** 2026-04-02
**Confidence:** HIGH **Confidence:** HIGH
## Executive Summary ## Executive Summary
This milestone is a pure UI/UX overhaul of an existing working application. The backend (Supabase schema, hooks, queries) is frozen. The stack is already in place — React 19, Vite 8, Tailwind CSS 4, Recharts 3.8.0, shadcn/ui, TanStack Query — and no new framework dependencies are needed. The overhaul adds three shadcn/ui primitives (`chart`, `accordion`/`collapsible`), extends the existing OKLCH color system, and introduces a structured component hierarchy for the dashboard. The core transformation is from a flat single-chart dashboard to a rich hybrid layout: summary KPI cards, three chart types (donut, vertical bar, horizontal bar), and collapsible per-category line-item sections. SimpleFinanceDash v2.0 is a UX simplification milestone on top of a fully working v1.0 app. All data infrastructure already exists — categories, templates, budget generation, charts, collapsible sections. The central problem is cognitive: new users land on a blank dashboard with no guidance, monthly budget creation requires a manual trigger, and the Quick Add feature lives on a disconnected page. The recommended approach is a three-part simplification: (1) a 3-step first-run wizard that seeds a usable template from curated defaults, (2) automatic monthly budget creation on dashboard visit when a template is populated, and (3) inline item-adding directly from the budget view to replace the isolated Quick Add page. Paired with a design system token rework (sharp radius, clearer pastels), these changes transform the app from a data entry tool into a coherent, guided experience.
The recommended approach is to establish design foundations first (tokens, shared components, build order primitives) before touching any page. Every visual decision — color, spacing, card style — must originate from `index.css` `@theme inline` tokens and a small set of shared components (`PageShell`, `StatCard`, `CategorySection`). Without this discipline, a multi-page overhaul will produce "island redesign" inconsistency: each page looks polished individually but the app feels fragmented during navigation. The dashboard phase comes second, followed by extending the design system to all remaining pages. The stack is locked and healthy. The only new dependencies are react-hook-form 7.72.0, zod 4.3.6, and @hookform/resolvers 5.2.2 for multi-step form validation in the wizard. Three additional shadcn/ui components are needed (progress, checkbox, scroll-area). All other work — the wizard stepper UI, design token changes, auto-budget triggering — is implemented through local code changes and SQL with no additional packages. Critically, no new DB migrations are required for features: all wizard and auto-budget writes go to existing `categories`, `template_items`, `budgets`, and `budget_items` tables via existing hooks and mutations. Two DB constraint migrations are required for safety (unique constraint on `budgets(user_id, start_date)` and `categories(user_id, name)`), and a one-time migration to set `profiles.setup_completed = true` for existing v1.0 users.
The highest-risk technical area is the Recharts + shadcn `chart.tsx` integration: the official shadcn PR for Recharts v3 is not yet merged, so the generated `chart.tsx` requires a known manual patch (add `initialDimension` to `ResponsiveContainer`). A close second risk is collapsible section layout shift — Recharts `ResponsiveContainer` instances react to parent height changes, and multiple instances on one page require `debounce={50}` and Radix `Collapsible` (not raw `display:none` toggle) to prevent ResizeObserver cascade errors. Both risks have documented fixes and are LOW recovery cost if addressed proactively. The primary risks are concurrency-related and data-integrity-related, not technical. Auto-budget creation via a client-side `useEffect` can fire multiple times (React StrictMode double-invocation, multi-tab, fast navigation), producing duplicate budget rows without a DB-level unique constraint. The wizard completion sequence is non-idempotent without explicit guards — a re-run creates duplicate categories and template items. The design token rework is a third systemic risk: changing global CSS variables (`--radius`, OKLCH color tokens) affects all 9 existing pages simultaneously. All three risks have clear, low-cost mitigations that must be applied before the relevant features ship, not after.
---
## Key Findings ## Key Findings
### Recommended Stack ### Recommended Stack
The stack is locked; no new npm packages are required. Three shadcn/ui components must be installed via CLI: `chart` (Recharts theme-aware wrappers), `collapsible` (Radix primitive for category sections), and optionally `accordion`. After running `npx shadcn@latest add chart`, a manual one-line fix to `chart.tsx` is required: add `initialDimension={{ width: 320, height: 200 }}` to the inner `ResponsiveContainer` (see shadcn-ui/ui issue #9892). The existing OKLCH color palette in `index.css` needs only two additions: richer chroma on category colors (bump from ~0.14 to ~0.18+) and semantic status tokens (`--color-over-budget`, `--color-on-budget`, `--color-budget-bar-bg`). The project runs on React 19 + Vite 8 + TypeScript 5.9 + Tailwind CSS 4 + Recharts 2.15.4 + TanStack Query v5 + Supabase (PostgreSQL + Auth + RLS). These versions are locked and must not be changed in this milestone. Note: prior research incorrectly listed Recharts 3.x — the actual installed version is 2.15.4 per `package.json`, and upgrading to v3 is explicitly out of scope due to breaking changes. The backend is Supabase, not a Go service — there is no Go source code in the repository.
**Core technologies and their roles for this milestone:** **Core technologies (new additions only):**
- **Recharts 3.8.0** — all three chart types (donut, bar, horizontal bar) are supported natively; stay on current version, do not introduce alternative chart libraries - `react-hook-form 7.72.0` + `zod 4.3.6` + `@hookform/resolvers 5.2.2` — multi-step wizard form validation; industry standard, 9.3kb bundle, handles per-step validation before advancing
- **shadcn `chart.tsx`** — `ChartContainer` + `ChartTooltipContent` wrappers provide CSS-variable-aware theming for all Recharts instances; required patch documented in issue #9892 - `shadcn checkbox`, `progress`, `scroll-area` — three new shadcn components needed for wizard and inline add panel
- **shadcn `Collapsible`** — Radix UI primitive that animates height via CSS custom property `--radix-collapsible-content-height`; the correct tool for per-category collapsible sections - Custom `<WizardStepper>` built from shadcn/ui `Button` + `Badge` + Tailwind — no external stepper library; none are dominant and all add bundle weight for 50 lines of local code
- **Tailwind CSS 4 `@theme inline`** — single source of truth for all color tokens; all component color values must reference CSS variables, never hardcoded hex - OKLCH two-tier pastel token system in `src/index.css` — surface tokens at L~0.95 for backgrounds, accent tokens at L~0.80 for borders/badges, existing text tokens at L~0.55 unchanged
- **`useMemo` in DashboardContent** — all chart data derivations must be memoized centrally; child chart components receive pre-computed data arrays and render only
**Critical version note:** Recharts 3 deprecated `Cell` in favor of `shape` prop, and removed `blendStroke` — use `stroke="none"` instead. Existing `Cell` usage still works but should not be extended.
### Expected Features ### Expected Features
Research grounded in competitor analysis (YNAB, Empower) and fintech UX standards. The full feature list lives in `FEATURES.md`. **Must have (table stakes — v2.0 launch):**
- 3-step first-run wizard: income → common items (pre-filled, editable, with live running balance) → review — skippable per step and as a whole
- Pre-seeded library of ~15-20 curated items grouped by category type with static sensible default amounts
- Auto-create current month's budget from template on dashboard visit — silent, no manual trigger
- First-creation toast notification only ("Your April budget was created from your template"); silent for all subsequent months
- Inline add-from-library on budget view as a shadcn Sheet panel — replaces the standalone Quick Add page
- Quick Add removed from sidebar navigation; route kept with redirect to `/budgets`
- Dashboard summary cards correctly reflecting current month budget data
- Empty state CTAs for: empty template page, empty dashboard, empty budget view
**Must have (table stakes — users expect these):** **Should have (competitive differentiators — v2.x):**
- Summary KPI cards (income / expenses / balance) with colored semantics and variance badges - "Edit template" shortcut link from budget view for quick access to change recurring items
- Donut chart with center total label and active hover expand - Persistent "this month" summary widget on the dashboard sidebar
- Horizontal bar chart: budget vs actual per category type - "Complete setup" banner for users who skipped the wizard but have an empty template
- Grouped bar chart: income budgeted vs actual - Running balance updating live during wizard item selection (income minus sum of selected items; frontend computation only)
- Collapsible per-category sections with line items and group totals
- Skeleton loading states that mirror the real layout structure
- Month navigation control on the dashboard (currently locked to current month)
- Consistent design language across all pages — the dashboard sets the pattern, all other pages must inherit it
**Should have (differentiators, high value for low cost):** **Defer (v3+):**
- Variance indicators (delta arrows/badges) on summary cards and section headers - AI-suggested amounts based on spending history — requires historical data and ML infrastructure; static defaults are sufficient
- Accent-colored category section borders (`border-l-4` with category CSS variable) - CSV/bank import for actuals — already noted as a future feature in PROJECT.md
- Empty state with actionable CTA for new months - Mandatory wizard / forced onboarding — explicitly an anti-feature; skippable is always correct
- Carryover amount surfaced on the dashboard balance card - Complex 6+ step wizard — drop-off increases sharply beyond step 3; confirmed by YNAB's own Oct 2025 update to shorten their onboarding
- Section collapse state preserved in localStorage (P3 polish)
**Defer to v2+:**
- Trend / multi-month charts — requires new query shape and chart layout; explicitly out of scope
- Dark mode — OKLCH infrastructure is ready but requires full contrast audit; doubles testing surface
- AI-derived insights — backend dependency; no scope here
- Drag-to-reorder categories — requires `sort_order` mutation support
### Architecture Approach ### Architecture Approach
The existing three-tier architecture (Pages → Hooks → Supabase) is preserved intact. The overhaul introduces a **View Components layer** that sits between pages and shadcn/ui primitives, and a small **Shared Components layer** for cross-page patterns. Hooks and library files are read-only during this milestone; all computation lives in the presentation layer via `useMemo`. The v2.0 architecture is additive, not structural. A new `WizardRoute` guard wraps the existing `ProtectedRoute` subtree in `App.tsx`. A new `/setup` route renders `SetupWizardPage` as a dedicated page (not a modal overlay — dedicated URL prevents broken refresh/back behavior). The wizard completes by writing to existing tables via existing hooks. Auto-budget creation is a `useEffect` trigger added to `DashboardPage` that calls the already-existing `generateFromTemplate` mutation. Inline add replaces `QuickAddPicker` with a new `AddOneOffSheet` component at both call sites. Dashboard simplification removes the 3-column chart grid from `DashboardContent` (charts stay in the codebase but are removed from the Dashboard layout). All existing hooks and mutations (`useBudgets.ts`, `useTemplate.ts`, `useCategories.ts`, etc.) are read-only for this milestone.
**New components and their responsibilities:** **New files:**
1. `src/hooks/useFirstRunState.ts` — detects first-run: `categories.length === 0 OR template_items.length === 0`
2. `src/data/presets.ts` — static preset category list with suggested amounts (compile-time data, no DB table)
3. `src/pages/SetupWizardPage.tsx` — 3-step wizard, local `useState` only, localStorage persistence
4. `src/components/wizard/WizardStep.tsx`, `CategoryDefaults.tsx`, `TemplateDefaults.tsx` — wizard UI pieces
5. `src/components/budget/AddOneOffSheet.tsx` — Sheet-based one-off item adder (replaces QuickAddPicker)
| Component | Location | Responsibility | **Modified files:** `src/App.tsx`, `src/components/AppLayout.tsx`, `src/pages/DashboardPage.tsx`, `src/pages/BudgetDetailPage.tsx`, `src/index.css`, `src/i18n/en.json`, `src/i18n/de.json`
|-----------|----------|----------------|
| `DashboardContent` | `components/dashboard/` | Orchestrator: owns `useMemo` derivations, passes typed props to all children |
| `SummaryStrip` + `StatCard` | `components/dashboard/` | KPI cards row with semantic color and variance badge |
| `ChartPanel` | `components/dashboard/` | Two-column responsive grid containing all three chart instances |
| `IncomeBarChart` | `components/dashboard/` | Budgeted vs actual income vertical bar, wrapped in `ChartContainer` |
| `ExpenseDonutChart` | `components/dashboard/` | Expense breakdown donut with center total and custom legend |
| `SpendBarChart` | `components/dashboard/` | Horizontal budget vs actual by category type |
| `CategorySection` | `components/dashboard/` | Radix `Collapsible` wrapping header row + `BudgetLineItems` |
| `BudgetLineItems` | `components/dashboard/` | Line-item table reusing existing `InlineEditCell` / `DifferenceCell` atoms |
| `PageShell` | `components/shared/` | Cross-page consistent header with title, optional description, CTA slot |
**Build order is strictly dependency-driven** (see Architecture section for full sequence): install shadcn primitives first, then `PageShell` and `StatCard`, then chart components, then `CategorySection`, then `DashboardContent` as final orchestrator, then all remaining page refreshes.
### Critical Pitfalls ### Critical Pitfalls
All six pitfalls in `PITFALLS.md` are rated HIGH confidence with verified fixes. The top five to actively prevent: 1. **Duplicate budget creation on concurrent auto-create** — Add `UNIQUE (user_id, start_date)` constraint to `budgets` table via migration AND use `INSERT ... ON CONFLICT DO NOTHING` in `generateFromTemplate` before any auto-create code ships. Use a `useRef(false)` guard in the `useEffect` to prevent double-fire from React StrictMode.
1. **Unmemoized chart data triggers triple re-renders** — every `items.filter().reduce()` in `DashboardContent` must be wrapped in `useMemo`. Adding three chart instances to a non-memoized render body multiplies re-render cost 3x and causes tooltip hover lag. Recovery cost is LOW but disrupts delivered work if caught late. 2. **Wizard creates duplicate categories on re-run** — Add `UNIQUE (user_id, name)` constraint to `categories` table; use upsert for category creation. Add `profiles.setup_completed` boolean; set to `true` in `onSuccess` only. Write a migration to backfill `setup_completed = true` for all v1.0 users with existing template items before shipping.
2. **shadcn `chart.tsx` incompatibility with Recharts 3** — the generated `chart.tsx` from `npx shadcn@latest add chart` will produce `width(-1) and height(-1)` warnings and potential layout failures. Apply the `initialDimension` fix to `ChartContainer` immediately after generation. This is a one-line fix with a documented workaround (issue #9892), but if skipped it causes all charts to silently render at zero dimensions. 3. **Design token rework breaks all 9 pages silently** — Token changes must be one isolated commit followed immediately by a full visual regression pass on all pages before any component code is touched. Never mix token changes and component changes in the same commit.
3. **Collapsible sections trigger ResizeObserver cascade on charts** — using `display:none` toggle or raw `height` animation on category sections causes Recharts `ResponsiveContainer` instances above/below to rapidly resize. Fix: use Radix `Collapsible` with CSS `@keyframes` on `--radix-collapsible-content-height`, and add `debounce={50}` to all `ResponsiveContainer` instances. 4. **Auto-budget defaults to wrong currency** — The `generateFromTemplate` mutation defaults to `"EUR"`. Auto-create must read `profile.currency` first and chain via TanStack Query's `enabled` flag: only auto-create after both the budgets list and user profile are loaded.
4. **Color accessibility regression during "rich visual" overhaul** — the redesign goal of rich colors is a direct risk factor for WCAG contrast failures. The existing `text-green-600` / `text-red-600` pattern must be audited (green-600 is borderline). All six category pie slice colors cluster at similar OKLCH lightness (~0.650.72) — vary lightness as well as hue. Supplement color with icons for status indicators; color alone is never sufficient. 5. **Wizard state lost on browser refresh** — Persist wizard state to `localStorage` keyed by `wizard_state_${userId}` on every step change; read on mount; clear on completion or skip. Use URL search params (`?step=2`) for browser back/forward support.
5. **i18n key regressions**`i18next` silently falls back to English when German keys are missing (`fallbackLng: 'en'`). Every new UI text key must be added to both `en.json` and `de.json` in the same commit. Run `i18next-scanner` or manually switch to German locale before marking any phase complete.
**Sixth pitfall to prevent at the outset:** Design inconsistency across page refreshes. If shared components (`StatCard`, `CategorySection` header pattern, card border accents) are not extracted before page work begins, each page will develop subtle visual drift that is expensive to correct retroactively.
---
## Implications for Roadmap ## Implications for Roadmap
Research points to a clear four-phase structure. The ordering is driven by: (1) the build dependency chain in ARCHITECTURE.md, (2) the "design foundation before page work" principle from PITFALLS.md, and (3) the feature dependency graph in FEATURES.md. Research points to a clear five-phase structure derived from the dependency chain in ARCHITECTURE.md, the feature dependency graph in FEATURES.md, and the phase-to-pitfall mapping in PITFALLS.md.
### Phase 1: Design Foundation and Primitives ### Phase 1: Design System Token Rework
**Rationale:** Multiple pitfalls converge on the same root cause — design tokens and shared components must exist before any page work begins. Design drift (Pitfall 6) and color accessibility failures (Pitfall 4) both require the palette and shared component abstractions to be established first. This phase has no external dependencies and produces the building blocks every subsequent phase consumes. **Rationale:** Token changes cascade globally to all 9 existing pages. This must land first, in isolation, establishing the visual baseline before any page component is built or modified. Mixing token changes with component changes makes regressions impossible to attribute.
**Delivers:** Extended `index.css` color tokens (richer chroma, semantic status tokens), installed shadcn primitives (`chart.tsx` with Recharts v3 patch applied, `collapsible.tsx`), `PageShell`, `StatCard` / `SummaryStrip` components. **Delivers:** `--radius: 0.125rem` (sharp edges), refined OKLCH pastel surface/accent tokens, warmer background, softer border — applied in `src/index.css` only, zero component-file changes.
**Addresses:** Summary KPI cards (table stakes), accent-colored category borders, semantic color system, skeleton components **Addresses:** Sharp minimal aesthetic direction; OKLCH two-tier pastel system for category colors
**Avoids:** Design inconsistency pitfall, color accessibility regression, CSS variable scope issues **Avoids:** Pitfall 3 (token rework breaks pages silently) — full visual pass on all 9 pages is the acceptance criterion before this phase closes.
**Research flag:** Standard patternsno additional research needed. Tailwind v4 `@theme inline` and WCAG contrast requirements are well-documented. **Research flag:** Standard pattern — CSS custom property overrides in Tailwind v4 `@theme inline` are fully documented. No deeper research needed.
### Phase 2: Dashboard Charts and Layout ### Phase 2: Preset Data, First-Run Detection, and DB Safety Constraints
**Rationale:** Charts depend on `chart.tsx` from Phase 1. The dashboard is the highest-value page and establishes the full visual language for all subsequent pages. Month navigation must be included here (not deferred) because it changes how `DashboardContent` holds state — retrofitting it after collapsible sections are built is more disruptive than including it upfront. **Rationale:** Both the wizard and auto-budget creation depend on knowing whether this is a first-run user and on DB constraints that prevent duplicate data. Static data and detection hook have no UI dependencies and can be built and tested in isolation. The DB constraints must exist before any write mutations are triggered.
**Delivers:** `DashboardContent` orchestrator with `useMemo` data derivations, `ChartPanel` with all three chart types (`IncomeBarChart`, `ExpenseDonutChart`, `SpendBarChart`), month navigation control, skeleton loading for charts and cards. **Delivers:** `src/data/presets.ts`, `src/hooks/useFirstRunState.ts`, DB migration for `UNIQUE (user_id, start_date)` on `budgets`, DB migration for `UNIQUE (user_id, name)` on `categories`, DB migration for `profiles.setup_completed` flag with backfill for v1.0 users.
**Addresses:** Donut chart (table stakes), horizontal bar chart (P1), grouped income bar chart (P1), skeleton loading (P1), month navigation (P1) **Addresses:** Accurate first-run detection; protection against duplicate data at the DB layer
**Avoids:** Unmemoized chart data pitfall (use `useMemo` from the start), `chart.tsx` Recharts v3 patch (apply in Phase 1 before this phase begins) **Avoids:** Pitfall 1 (duplicate budgets), Pitfall 6 (duplicate categories), Pitfall 8 (first-run gate triggers for existing users)
**Research flag:** Standard patterns — Recharts 3.8.0 chart implementations are fully documented. The specific `chart.tsx` fix is documented in issue #9892. **Research flag:** Standard pattern. The `setup_completed` backfill migration needs a SQL audit of the `profiles` table structure before writing — low-effort verification.
### Phase 3: Collapsible Dashboard Sections ### Phase 3: Setup Wizard
**Rationale:** `CategorySection` depends on `Collapsible` primitive (Phase 1) and `BudgetLineItems` (which reuses existing `InlineEditCell` atoms). This phase brings the dashboard to full feature completeness. Separating it from Phase 2 keeps each phase focused and ensures chart layout is stable before section collapse animations interact with chart resize behavior. **Rationale:** Depends on Phase 2 (presets data, first-run hook, DB constraints). The wizard is the core new-user experience. Building it after the data layer and constraints ensures the completion flow has correct idempotency guards from the start rather than retrofitting them later.
**Delivers:** `CategorySection` components with Radix `Collapsible`, `BudgetLineItems` table, group totals row, variance indicators in section headers, carryover amount on balance card. **Delivers:** `/setup` route, `SetupWizardPage` (3 steps: income → items → review), wizard components, `WizardRoute` guard in `App.tsx`, localStorage state persistence, bilingual i18n keys for all wizard text.
**Addresses:** Collapsible sections with line items (P1), totals per section (table stakes), variance indicators (P2), carryover amount (P2) **Addresses:** Wizard-style first-run setup, pre-filled category items, skippable steps, running balance display
**Avoids:** Collapsible layout shift pitfall — use Radix `Collapsible` with CSS keyframe animation and `debounce={50}` on all `ResponsiveContainer` instances; never `display:none` toggle **Avoids:** Pitfall 2 (wizard state lost on refresh) and Pitfall 6 (duplicate categories on re-run via idempotency guards)
**Research flag:** Standard patterns — Radix Collapsible API is well-documented. The `--radix-collapsible-content-height` animation pattern is the established approach. **Uses:** `react-hook-form` + `zod` + `@hookform/resolvers` (new dependencies), `checkbox` shadcn component
**Research flag:** `react-hook-form` + `zod` integration is the standard shadcn/ui documented form pattern. Custom `WizardStepper` using shadcn primitives is a copy-paste block. No deeper research needed.
### Phase 4: Full-App Design Consistency ### Phase 4: Auto-Budget Creation
**Rationale:** The dashboard establishes the design token application patterns. All other pages inherit from it. This phase is sequenced last because it depends on `PageShell` and the card/color patterns being proven on the dashboard first. FEATURES.md dependency graph makes this explicit: "all-pages redesign depends on dashboard being finished first." **Rationale:** Depends on Phase 3 — a populated template must be possible before auto-creation is meaningful. Also depends on Phase 2 DB constraints being in place. The profile currency dependency must be resolved here, not deferred.
**Delivers:** `PageShell` applied to all 9 pages, consistent card/color/typography treatment on `BudgetDetailPage` (highest urgency — users navigate here directly from dashboard), `BudgetListPage`, `CategoriesPage`, `TemplatePage`, `LoginPage`, `RegisterPage`, `QuickAddPage`, `SettingsPage`. Empty state pattern for new months. **Delivers:** Auto-create `useEffect` in `DashboardPage` (with `useRef` guard), profile currency read (verify `profiles` table schema), upsert-based budget creation, first-creation toast notification via `sonner`.
**Addresses:** Consistent design language (table stakes), BudgetDetailPage polish (P1), remaining page polish (P2), empty states (P2) **Addresses:** Auto-created budget on first month visit, correct currency denomination for all users, silent creation for all subsequent months
**Avoids:** i18n key regressionsswitch to German locale and run key parity check before completing each sub-page **Avoids:** Pitfall 1 (concurrent duplicate creation — DB constraint + ON CONFLICT guard) and Pitfall 5 (wrong currency — profile dependency resolved before mutation fires)
**Research flag:** Standard patterns — `PageShell` wrapper is straightforward. The main risk is thoroughness (9 pages), not technical complexity. **Research flag:** Verify the `profiles` table schema for the currency column name before writing the auto-create code. STACK.md flags this as needing confirmation. Low-effort: check `supabase/migrations/` directly.
### Phase 5: Inline Add-from-Library and Dashboard Simplification
**Rationale:** Depends on Phase 4 — inline add is only meaningful when a budget exists for the current month. Dashboard simplification (removing the chart grid) is deferred until auto-creation is working so there is no regression window where the dashboard shows only empty state. Quick Add deprecation with redirect belongs in the same phase as the replacement being functional.
**Delivers:** `AddOneOffSheet` component replacing `QuickAddPicker` at both call sites, removal of QuickAdd from sidebar nav, redirect from `/quick-add` to `/budgets`, removal of 3-column chart grid from `DashboardContent`, `scroll-area` shadcn component.
**Addresses:** Inline add-from-library on budget view, Quick Add page consolidation, dashboard "at a glance" simplification, empty state for removed chart area
**Avoids:** Pitfall 4 (Quick Add removal breaks nav/bookmarks — redirect added in same commit as nav removal)
**Research flag:** Standard component replacement pattern. No deeper research needed.
### Phase Ordering Rationale ### Phase Ordering Rationale
- **Foundation before features:** Pitfall 6 (design inconsistency) has a HIGH recovery cost. Establishing `index.css` tokens and shared components in Phase 1 prevents the most expensive failure mode. - **Tokens first** because they are global-scope — any token change touches all pages. Establishing them before page work begins means every page built afterward is already on the correct visual foundation.
- **Dashboard before other pages:** FEATURES.md dependency graph is explicit — the dashboard establishes the patterns all pages inherit. Building it second (after foundation) lets Phase 4 apply proven patterns. - **Data + constraints before UI** because the wizard's correctness depends on DB-level deduplication and accurate first-run detection. Building the wizard UI first and adding guards later is the most common cause of data corruption in this pattern.
- **Charts before collapsibles:** `ChartPanel` layout must be stable before collapsible sections are added beneath it. The ResizeObserver pitfall (Pitfall 3) is easiest to test and fix when charts are the only moving part. - **Wizard before auto-budget** because auto-budget is only meaningful when there is a template to generate from. The wizard is the mechanism that creates the template. E2E testing of auto-budget requires a wizard-populated template.
- **Sections in their own phase:** The collapsible + ResizeObserver interaction is the trickiest technical integration. Isolating it in Phase 3 limits blast radius if issues arise. - **Auto-budget before inline add** because inline add requires a current-month budget to exist. The auto-creation flow provides that guarantee.
- This ordering satisfies all 9 pitfall prevention phases documented in PITFALLS.md.
### Research Flags ### Research Flags
Phases with standard, well-documented patterns (skip `/gsd:research-phase`): Phases needing specific verification at planning time:
- **Phase 1:** Tailwind v4 `@theme inline`, OKLCH color tokens, and WCAG contrast requirements are all from official documentation with no ambiguity. - **Phase 4 (Auto-Budget Creation):** Verify the `profiles` table schema — specifically the `currency` column name and whether `useProfile` or `useAuth` is the correct hook to read it from. STACK.md flags this as a "verify in planning" item. Action: check `supabase/migrations/001_profiles.sql` before writing the auto-create spec.
- **Phase 2:** Recharts 3.8.0 chart implementations and the `chart.tsx` fix are fully documented. Month navigation via `useBudgets` hook is a straightforward state change in `DashboardContent`.
- **Phase 3:** Radix Collapsible primitive is well-documented. The animation pattern with `--radix-collapsible-content-height` is the standard approach.
- **Phase 4:** `PageShell` application and page-by-page refresh are repetitive pattern application, not novel implementation.
No phases require pre-execution research. All major decisions are resolved by this research. Phases with standard patterns (skip research-phase):
- **Phase 1:** Tailwind v4 `@theme inline` and OKLCH token overrides — fully documented, established pattern in this codebase.
--- - **Phase 2:** Static data file, boolean flag migration, unique constraint migrations — no novel patterns.
- **Phase 3:** `react-hook-form` + `zod` is the standard shadcn/ui form pattern; wizard stepper is a shadcn/ui blocks copy-paste.
- **Phase 5:** Component replacement using existing hooks and shadcn Sheet — established pattern within this codebase.
## Confidence Assessment ## Confidence Assessment
| Area | Confidence | Notes | | Area | Confidence | Notes |
|------|------------|-------| |------|------------|-------|
| Stack | HIGH | Stack is locked and fully inspected. The one uncertainty (shadcn `chart.tsx` + Recharts v3 compatibility) has a documented fix in issue #9892. | | Stack | HIGH | Verified against `package.json` and `supabase/migrations/`; actual installed versions confirmed; new dependencies verified via npm (April 2026); prior research's Recharts 3.x claim corrected |
| Features | HIGH | Grounded in competitor analysis (YNAB, Empower) and fintech UX standards. Feature prioritization is opinionated and defensible. | | Features | MEDIUM | Grounded in competitor analysis (YNAB, Monarch, EveryDollar, Quicken Simplifi) and UX onboarding literature; no first-party user testing data for this specific app's users |
| Architecture | HIGH | Based on full codebase inspection. Component boundaries and build order are derived from actual file structure and import dependencies. | | Architecture | HIGH | Based on full codebase inspection; all hook names, mutation signatures, component locations, and file paths verified against source files |
| Pitfalls | HIGH | Most pitfalls verified against official docs and open Recharts/shadcn issues. Recovery strategies and warning signs documented for each. | | Pitfalls | HIGH | Direct codebase analysis for each pitfall; React StrictMode double-invocation and TanStack Query mutation patterns are well-understood; DB constraint behaviors are standard PostgreSQL |
**Overall confidence: HIGH** **Overall confidence:** HIGH
### Gaps to Address ### Gaps to Address
- **shadcn `chart.tsx` patch (issue #9892):** The `initialDimension` fix is community-verified but not yet merged into the official shadcn CLI output. Apply manually after generation and verify with a smoke test (render one chart, confirm no `width(-1)` warnings in console) before proceeding with Phase 2. - **`profiles` table currency column:** STACK.md's RPC SQL assumes a `currency` column on `profiles`. PITFALLS.md flags that auto-create defaults to EUR without reading it. Verify column name before Phase 4 begins — check `supabase/migrations/001_profiles.sql`. Low-effort, one-minute check.
- **WCAG contrast on category colors:** The recommended OKLCH values in STACK.md (chroma bumped to ~0.18+) have not been run through a contrast checker. During Phase 1, verify each category color pair against its background at the target luminance levels. Adjust chroma or lightness if any pair fails 3:1 (non-text) or 4.5:1 (text) thresholds.
- **i18n key count baseline:** Before starting the overhaul, run `i18next-scanner` (or manually audit `en.json` vs `de.json`) to establish a baseline parity count. This makes regression detection in each subsequent phase mechanical rather than manual.
- **`BudgetListPage` data shape:** Research did not inspect `BudgetListPage` implementation in detail. Phase 4 may uncover layout decisions that conflict with the dashboard's card pattern. Plan for one iteration pass on `BudgetListPage` specifically.
--- - **`setup_completed` backfill migration:** This migration must ship before or simultaneously with Phase 3 to prevent existing v1.0 users from hitting the wizard on their first v2.0 login. Write and test it in Phase 2; it is a prerequisite for Phase 3 deployment.
- **i18n for pre-filled wizard item names:** The ~15-20 preset items in `presets.ts` have English names (Rent, Groceries, Car Insurance, etc.). German translations must exist in `de.json` before the wizard ships. Define translation keys directly in `presets.ts` at data-definition time to make the i18n requirement explicit and auditable.
- **`quick_add_items` user data consideration:** Existing users may have data in `quick_add_items` that no longer surfaces in the UI after Phase 5. The new inline add uses `categories`, not `quick_add_items`. No migration is planned to bridge these. Acceptable risk for a personal/single-user app, but worth noting in user-facing release notes if the app has multiple users.
- **TanStack Query v5 `useEffect` mutation pattern:** PITFALLS.md recommends against firing mutations inside `useEffect` with data dependencies. ARCHITECTURE.md uses exactly this pattern (with a `useRef` guard). Verify the `useRef(false)` guard is sufficient for TanStack Query v5's mutation lifecycle — specifically that `mutate` called on an already-pending mutation is silently ignored (it is, per TQ v5 docs, but confirm in implementation).
## Sources ## Sources
### Primary (HIGH confidence — official documentation) ### Primary (HIGH confidence)
- [Recharts 3.0 Migration Guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide) — v3 breaking changes, `Cell` deprecation, `blendStroke` removal - `package.json` (project root) — authoritative installed versions; confirmed Recharts 2.15.4 not 3.x
- [Recharts API: ResponsiveContainer](https://recharts.github.io/en-US/api/ResponsiveContainer/) — `debounce` prop, dimension requirements - `supabase/migrations/002_categories.sql` through `005_quick_add.sql` — authoritative schema
- [shadcn/ui Chart Docs](https://ui.shadcn.com/docs/components/chart) — `ChartContainer`, `ChartConfig`, `ChartTooltipContent` - `src/App.tsx`, `src/components/AppLayout.tsx`, `src/pages/DashboardPage.tsx`, `src/pages/BudgetDetailPage.tsx` — confirmed existing routing, nav items, hook usage patterns
- [shadcn/ui Accordion + Collapsible Docs](https://ui.shadcn.com/docs/components/radix/accordion) — component API, `type="multiple"`, independent state - `src/hooks/useBudgets.ts`, `src/hooks/useTemplate.ts`, `src/hooks/useCategories.ts` — confirmed existing mutation signatures
- [Radix UI Collapsible](https://www.radix-ui.com/primitives/docs/components/collapsible) — `--radix-collapsible-content-height` animation pattern - `src/index.css`, `src/lib/palette.ts`, `src/lib/types.ts` — confirmed token structure and CSS variable names
- [Tailwind CSS v4 Theme Docs](https://tailwindcss.com/docs/theme) — `@theme inline`, CSS variable scoping, dark mode class strategy - npm search results (April 2026) — react-hook-form 7.72.0, zod 4.3.6, @hookform/resolvers 5.2.2
- [WCAG 2.1 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG21/Understanding/non-text-contrast.html) — 3:1 minimum for UI components and chart elements - [shadcn/ui blocks page](https://ui.shadcn.com/blocks) — stepper patterns available as copy-paste blocks, no npm dependency
- [WCAG SC 1.4.3 Contrast Minimum](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) — 4.5:1 for normal text
- [React useMemo](https://react.dev/reference/react/useMemo) — memoization patterns for derived state
- Existing codebase: full inspection of `src/pages/`, `src/components/`, `src/hooks/`, `src/lib/`, `src/index.css`
### Secondary (MEDIUM confidence — community sources, multiple corroborating) ### Secondary (MEDIUM confidence)
- [shadcn-ui/ui PR #8486](https://github.com/shadcn-ui/ui/pull/8486) — Recharts v3 chart.tsx upgrade (open as of March 2026) - YNAB, Monarch Money, EveryDollar, Quicken Simplifi — competitor feature patterns for wizard setup, auto-budget creation, and inline item adding
- [shadcn-ui/ui Issue #9892](https://github.com/shadcn-ui/ui/issues/9892) — Community-verified `initialDimension` fix for Recharts v3 - [zod.dev/v4](https://zod.dev/v4) — Zod 4 stable release notes and versioning strategy
- [Recharts performance guide](https://recharts.github.io/en-US/guide/performance/) — memoization guidance - [Evil Martians OKLCH guide](https://evilmartians.com/chronicles/better-dynamic-themes-in-tailwind-with-oklch-color-magic) — lightness/chroma values for pastel system design
- [Recharts ResizeObserver loop issue #1770](https://github.com/recharts/recharts/issues/1770) — confirmed bug and `debounce` workaround - UX onboarding literature (UX Design Institute, UXCam, UXDA, Smashing Magazine) — wizard step count drop-off data, sheet vs. modal UX rationale
- [YNAB / Empower competitor analysis](https://bountisphere.com/blog/personal-finance-apps-2025-review) — feature comparison basis - [Recharts 3.0 migration guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide) — confirmed breaking changes; basis for "do not upgrade" recommendation
- [Fintech dashboard design best practices — Eleken, Merge Rocks, F9 Finance](https://merge.rocks/blog/fintech-dashboard-design-or-how-to-make-data-look-pretty) — visual design conventions
- [shadcn/ui Charts examples](https://ui.shadcn.com/charts) — Donut with center text, bar chart patterns
### Tertiary (informing but not authoritative) ### Tertiary (LOW confidence — informing but not authoritative)
- [Skeleton loading UX — LogRocket](https://blog.logrocket.com/ux-design/skeleton-loading-screen-design/) — skeleton mirrors real layout - TanStack Query v5 Strict Mode double-invocation behavior — inferred from known React 18/19 `useEffect` patterns; verify against TQ v5 mutation docs if unexpected behavior appears during implementation
- [Empty state UX — Eleken](https://www.eleken.co/blog-posts/empty-state-ux) — CTA pattern for empty states - PostgreSQL `ON CONFLICT DO NOTHING` behavior on Supabase PostgREST layer — standard DB behavior but confirm the exact upsert syntax works via the `@supabase/supabase-js` v2 client
- [Color theory in finance dashboards — Extej/Medium](https://medium.com/@extej/the-role-of-color-theory-in-finance-dashboard-design-d2942aec9fff) — palette chroma recommendations
--- ---
*Research completed: 2026-04-02*
*Research completed: 2026-03-16*
*Ready for roadmap: yes* *Ready for roadmap: yes*

805
bun.lock Normal file
View File

@@ -0,0 +1,805 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "simple-finance-dash",
"dependencies": {
"@supabase/supabase-js": "^2.99.1",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"react-router-dom": "^7.13.1",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="],
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
"@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
"@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
"@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
"@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm" }, "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.9", "", { "os": "linux", "cpu": "x64" }, "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.9", "", { "os": "none", "cpu": "arm64" }, "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.9", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.9", "", { "os": "win32", "cpu": "x64" }, "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@supabase/auth-js": ["@supabase/auth-js@2.99.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-x7lKKTvKjABJt/FYcRSPiTT01Xhm2FF8RhfL8+RHMkmlwmRQ88/lREupIHKwFPW0W6pTCJqkZb7Yhpw/EZ+fNw=="],
"@supabase/functions-js": ["@supabase/functions-js@2.99.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-WQE62W5geYImCO4jzFxCk/avnK7JmOdtqu2eiPz3zOaNiIJajNRSAwMMDgEGd2EMs+sUVYj1LfBjfmW3EzHgIA=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.99.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-gtw2ibJrADvfqrpUWXGNlrYUvxttF4WVWfPpTFKOb2IRj7B6YRWMDgcrYqIuD4ZEabK4m6YKQCCGy6clgf1lPA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.99.1", "", { "dependencies": { "@types/phoenix": "^1.6.6", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-9EDdy/5wOseGFqxW88ShV9JMRhm7f+9JGY5x+LqT8c7R0X1CTLwg5qie8FiBWcXTZ+68yYxVWunI+7W4FhkWOg=="],
"@supabase/storage-js": ["@supabase/storage-js@2.99.1", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-mf7zPfqofI62SOoyQJeNUVxe72E4rQsbWim6lTDPeLu3lHija/cP5utlQADGrjeTgOUN6znx/rWn7SjrETP1dw=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.99.1", "", { "dependencies": { "@supabase/auth-js": "2.99.1", "@supabase/functions-js": "2.99.1", "@supabase/postgrest-js": "2.99.1", "@supabase/realtime-js": "2.99.1", "@supabase/storage-js": "2.99.1" } }, "sha512-5MRoYD9ffXq8F6a036dm65YoSHisC3by/d22mauKE99Vrwf792KxYIIr/iqCX7E4hkuugbPZ5EGYHTB7MKy6Vg=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
"i18next": ["i18next@25.8.18", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-i18next": ["react-i18next@16.5.8", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="],
"react-router-dom": ["react-router-dom@7.13.1", "", { "dependencies": { "react-router": "7.13.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleFinanceDash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "simple-finance-dash",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.99.1",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.8.18",
"lucide-react": "^0.577.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.8",
"react-router-dom": "^7.13.1",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

65
src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { Routes, Route, Navigate } from "react-router-dom"
import { useAuth } from "@/hooks/useAuth"
import AppLayout from "@/components/AppLayout"
import LoginPage from "@/pages/LoginPage"
import RegisterPage from "@/pages/RegisterPage"
import DashboardPage from "@/pages/DashboardPage"
import CategoriesPage from "@/pages/CategoriesPage"
import TemplatePage from "@/pages/TemplatePage"
import BudgetListPage from "@/pages/BudgetListPage"
import BudgetDetailPage from "@/pages/BudgetDetailPage"
import QuickAddPage from "@/pages/QuickAddPage"
import SettingsPage from "@/pages/SettingsPage"
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return null
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return null
if (user) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<Routes>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="categories" element={<CategoriesPage />} />
<Route path="template" element={<TemplatePage />} />
<Route path="budgets" element={<BudgetListPage />} />
<Route path="budgets/:id" element={<BudgetDetailPage />} />
<Route path="quick-add" element={<QuickAddPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,100 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import {
LayoutDashboard,
Tags,
FileText,
Wallet,
Zap,
Settings,
LogOut,
} from "lucide-react"
import { useAuth } from "@/hooks/useAuth"
import { cn } from "@/lib/utils"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarInset,
} from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
const navItems = [
{ path: "/", icon: LayoutDashboard, labelKey: "nav.dashboard" },
{ path: "/categories", icon: Tags, labelKey: "nav.categories" },
{ path: "/template", icon: FileText, labelKey: "nav.template" },
{ path: "/budgets", icon: Wallet, labelKey: "nav.budgets" },
{ path: "/quick-add", icon: Zap, labelKey: "nav.quickAdd" },
{ path: "/settings", icon: Settings, labelKey: "nav.settings" },
] as const
export default function AppLayout() {
const { t } = useTranslation()
const { signOut } = useAuth()
const navigate = useNavigate()
async function handleLogout() {
await signOut()
navigate("/login")
}
return (
<SidebarProvider>
<Sidebar>
<SidebarHeader className="p-4">
<h1 className="text-lg font-semibold tracking-tight">
{t("app.title")}
</h1>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton asChild>
<NavLink
to={item.path}
end={item.path === "/"}
className={({ isActive }) =>
cn(isActive && "bg-sidebar-accent font-medium")
}
>
<item.icon className="size-4" />
<span>{t(item.labelKey)}</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-4">
<Separator className="mb-4" />
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleLogout}
>
<LogOut className="size-4" />
{t("nav.logout")}
</Button>
</SidebarFooter>
</Sidebar>
<SidebarInset>
<main className="flex-1 p-6">
<Outlet />
</main>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,252 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Zap } from "lucide-react"
import { toast } from "sonner"
import { useQuickAdd } from "@/hooks/useQuickAdd"
import { useCategories } from "@/hooks/useCategories"
import { useBudgets } from "@/hooks/useBudgets"
import type { QuickAddItem, 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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CATEGORY_TYPES: CategoryType[] = [
"income",
"bill",
"variable_expense",
"debt",
"saving",
"investment",
]
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface QuickAddPickerProps {
/** The id of the current month's budget to add the item to. */
budgetId: string
}
// ---------------------------------------------------------------------------
// QuickAddPicker
// ---------------------------------------------------------------------------
/**
* A Popover + Dialog component that lets users quickly add a one-off budget
* item from their saved quick-add library.
*
* Flow:
* 1. Click "Quick Add" button → Popover opens listing saved quick-add items.
* 2. Select an item → Popover closes, confirmation Dialog opens.
* 3. Choose a category and enter an amount → save adds a one_off budget item.
*/
export default function QuickAddPicker({ budgetId }: QuickAddPickerProps) {
const { t } = useTranslation()
const { items } = useQuickAdd()
const { categories } = useCategories()
const { createItem } = useBudgets()
// Popover open state
const [popoverOpen, setPopoverOpen] = useState(false)
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedItem, setSelectedItem] = useState<QuickAddItem | null>(null)
const [categoryId, setCategoryId] = useState("")
const [amount, setAmount] = useState("")
// ------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------
function handlePickItem(item: QuickAddItem) {
setSelectedItem(item)
setCategoryId("")
setAmount("")
setPopoverOpen(false)
setDialogOpen(true)
}
function handleDialogClose() {
setDialogOpen(false)
setSelectedItem(null)
setCategoryId("")
setAmount("")
}
async function handleSave() {
const parsed = parseFloat(amount)
if (!categoryId || isNaN(parsed) || parsed < 0) return
try {
await createItem.mutateAsync({
budgetId,
category_id: categoryId,
budgeted_amount: parsed,
actual_amount: parsed,
notes: selectedItem?.name ?? null,
})
handleDialogClose()
toast.success(selectedItem?.name ?? t("budgets.addItem"))
} catch {
toast.error(t("common.error"))
}
}
const canSave =
Boolean(categoryId) &&
Boolean(amount) &&
!isNaN(parseFloat(amount)) &&
parseFloat(amount) >= 0
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
return (
<>
{/* Trigger popover */}
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Zap className="mr-1.5 size-4" />
{t("nav.quickAdd")}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
{items.length === 0 ? (
<p className="px-2 py-3 text-center text-sm text-muted-foreground">
{t("quickAdd.empty")}
</p>
) : (
<ul role="listbox" aria-label={t("nav.quickAdd")}>
{items.map((item) => (
<li key={item.id}>
<button
type="button"
role="option"
aria-selected={false}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
onClick={() => handlePickItem(item)}
>
{item.icon && (
<span className="shrink-0 text-base leading-none">
{item.icon}
</span>
)}
<span className="truncate">{item.name}</span>
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
{/* Confirmation / detail dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && handleDialogClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedItem?.icon && (
<span className="mr-2">{selectedItem.icon}</span>
)}
{selectedItem?.name ?? t("budgets.addItem")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Category picker */}
<div className="space-y-2">
<Label>{t("quickAdd.pickCategory")}</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<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>
{/* Amount */}
<div className="space-y-2">
<Label>{t("quickAdd.amount")}</Label>
<Input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleDialogClose}
disabled={createItem.isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || createItem.isPending}
>
{t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,165 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
onPointerDownOutside={(e) => {
// Prevent Dialog from closing when interacting with Select/Popover portals
const target = e.target as HTMLElement
if (target.closest("[data-radix-select-content]") || target.closest("[data-slot=select-content]")) {
e.preventDefault()
}
}}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -0,0 +1,188 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

141
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,141 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,724 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,36 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

19
src/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

57
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,57 @@
import { useEffect, useState } from "react"
import type { Session, User } from "@supabase/supabase-js"
import { supabase } from "@/lib/supabase"
export function useAuth() {
const [session, setSession] = useState<Session | null>(null)
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
setUser(session?.user ?? null)
setLoading(false)
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
setUser(session?.user ?? null)
setLoading(false)
})
return () => subscription.unsubscribe()
}, [])
async function signUp(email: string, password: string) {
const { error } = await supabase.auth.signUp({ email, password })
if (error) throw error
}
async function signIn(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
}
async function signInWithOAuth(provider: "google" | "github") {
const { error } = await supabase.auth.signInWithOAuth({ provider })
if (error) throw error
}
async function signOut() {
const { error } = await supabase.auth.signOut()
if (error) throw error
}
return {
session,
user,
loading,
signUp,
signIn,
signInWithOAuth,
signOut,
}
}

368
src/hooks/useBudgets.ts Normal file
View File

@@ -0,0 +1,368 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { Budget, BudgetItem } from "@/lib/types"
// ---------------------------------------------------------------------------
// Query keys
// ---------------------------------------------------------------------------
const BUDGETS_KEY = ["budgets"] as const
function budgetKey(id: string) {
return ["budgets", id] as const
}
function budgetItemsKey(id: string) {
return ["budgets", id, "items"] as const
}
// ---------------------------------------------------------------------------
// Date helpers
// ---------------------------------------------------------------------------
/**
* Given a 1-based month and a full year, return ISO date strings for the
* first and last day of that month.
*/
function monthBounds(
month: number,
year: number
): { start_date: string; end_date: string } {
const start = new Date(year, month - 1, 1)
const end = new Date(year, month, 0) // day 0 of next month = last day of current
function fmt(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const dd = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${dd}`
}
return { start_date: fmt(start), end_date: fmt(end) }
}
// ---------------------------------------------------------------------------
// Main hook
// ---------------------------------------------------------------------------
export function useBudgets() {
const queryClient = useQueryClient()
// ------------------------------------------------------------------
// Query: list all budgets, newest first
// ------------------------------------------------------------------
const budgetsQuery = useQuery({
queryKey: BUDGETS_KEY,
queryFn: async () => {
const { data, error } = await supabase
.from("budgets")
.select("*")
.order("start_date", { ascending: false })
if (error) throw error
return data as Budget[]
},
})
// ------------------------------------------------------------------
// Factory: return a query hook for a single budget with its items
//
// Callers use this as:
// const detail = useBudgets().getBudget(id)
// detail.budget, detail.items, detail.loading
//
// Note: this returns a *function* — callers must call it at the top
// level of a component. For convenience `useBudgetDetail` is also
// exported below.
// ------------------------------------------------------------------
function getBudget(id: string) {
return useBudgetDetail(id)
}
// ------------------------------------------------------------------
// Mutation: create an empty budget for a given month + year
// ------------------------------------------------------------------
const createBudget = useMutation({
mutationFn: async ({
month,
year,
currency = "EUR",
}: {
month: number
year: number
currency?: string
}) => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error("Not authenticated")
const { start_date, end_date } = monthBounds(month, year)
const { data, error } = await supabase
.from("budgets")
.insert({ user_id: user.id, start_date, end_date, currency, carryover_amount: 0 })
.select()
.single()
if (error) throw error
return data as Budget
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: BUDGETS_KEY }),
})
// ------------------------------------------------------------------
// Mutation: generate a budget from the user's template
//
// Steps:
// 1. Create the budget row
// 2. Fetch the user's template + template_items
// 3. Bulk-insert corresponding budget_items
// ------------------------------------------------------------------
const generateFromTemplate = useMutation({
mutationFn: async ({
month,
year,
currency = "EUR",
}: {
month: number
year: number
currency?: string
}) => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error("Not authenticated")
// 1. Create the budget row
const { start_date, end_date } = monthBounds(month, year)
const { data: budget, error: budgetError } = await supabase
.from("budgets")
.insert({ user_id: user.id, start_date, end_date, currency, carryover_amount: 0 })
.select()
.single()
if (budgetError) throw budgetError
// 2. Find the user's template
const { data: template, error: templateError } = await supabase
.from("templates")
.select("id")
.eq("user_id", user.id)
.maybeSingle()
if (templateError) throw templateError
if (!template) {
// No template exists yet — return the empty budget
return budget as Budget
}
// 3. Fetch template items
const { data: templateItems, error: itemsError } = await supabase
.from("template_items")
.select("*")
.eq("template_id", template.id)
.order("sort_order")
if (itemsError) throw itemsError
if (!templateItems || templateItems.length === 0) {
return budget as Budget
}
// 4. Bulk-insert budget_items copying the template
const budgetItemsPayload = templateItems.map(
(ti: {
category_id: string
item_tier: string
budgeted_amount: number
}) => ({
budget_id: (budget as Budget).id,
category_id: ti.category_id,
item_tier: ti.item_tier,
budgeted_amount: ti.budgeted_amount,
actual_amount: 0,
notes: null,
})
)
const { error: insertError } = await supabase
.from("budget_items")
.insert(budgetItemsPayload)
if (insertError) throw insertError
return budget as Budget
},
onSuccess: (budget) => {
queryClient.invalidateQueries({ queryKey: BUDGETS_KEY })
queryClient.invalidateQueries({ queryKey: budgetKey(budget.id) })
queryClient.invalidateQueries({ queryKey: budgetItemsKey(budget.id) })
},
})
// ------------------------------------------------------------------
// Mutation: update a budget item (budgeted_amount, actual_amount, notes)
// ------------------------------------------------------------------
const updateItem = useMutation({
mutationFn: async ({
id,
budgetId,
...updates
}: {
id: string
budgetId: string
} & Partial<Pick<BudgetItem, "budgeted_amount" | "actual_amount" | "notes">>) => {
const { data, error } = await supabase
.from("budget_items")
.update(updates)
.eq("id", id)
.select("*, category:categories(*)")
.single()
if (error) throw error
return { item: data as BudgetItem, budgetId }
},
onSuccess: ({ budgetId }) => {
queryClient.invalidateQueries({ queryKey: budgetItemsKey(budgetId) })
},
})
// ------------------------------------------------------------------
// Mutation: add a new one-off budget item
// ------------------------------------------------------------------
const createItem = useMutation({
mutationFn: async ({
budgetId,
category_id,
budgeted_amount,
actual_amount = 0,
notes = null,
}: {
budgetId: string
category_id: string
budgeted_amount: number
actual_amount?: number
notes?: string | null
}) => {
const { data, error } = await supabase
.from("budget_items")
.insert({
budget_id: budgetId,
category_id,
item_tier: "one_off",
budgeted_amount,
actual_amount,
notes,
})
.select("*, category:categories(*)")
.single()
if (error) throw error
return { item: data as BudgetItem, budgetId }
},
onSuccess: ({ budgetId }) => {
queryClient.invalidateQueries({ queryKey: budgetItemsKey(budgetId) })
},
})
// ------------------------------------------------------------------
// Mutation: delete a budget item
// ------------------------------------------------------------------
const deleteItem = useMutation({
mutationFn: async ({ id, budgetId }: { id: string; budgetId: string }) => {
const { error } = await supabase
.from("budget_items")
.delete()
.eq("id", id)
if (error) throw error
return { budgetId }
},
onSuccess: ({ budgetId }) => {
queryClient.invalidateQueries({ queryKey: budgetItemsKey(budgetId) })
},
})
// ------------------------------------------------------------------
// Mutation: delete an entire budget (cascades to budget_items via FK)
// ------------------------------------------------------------------
const deleteBudget = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("budgets").delete().eq("id", id)
if (error) throw error
return id
},
onSuccess: (id) => {
queryClient.invalidateQueries({ queryKey: BUDGETS_KEY })
queryClient.removeQueries({ queryKey: budgetKey(id) })
queryClient.removeQueries({ queryKey: budgetItemsKey(id) })
},
})
// ------------------------------------------------------------------
// Exposed API
// ------------------------------------------------------------------
return {
/** All budgets for the current user, newest first. */
budgets: budgetsQuery.data ?? [],
loading: budgetsQuery.isLoading,
/**
* Returns a single-budget query hook. Call this at the top level of
* a component, or use the standalone `useBudgetDetail(id)` export.
*/
getBudget,
createBudget,
generateFromTemplate,
updateItem,
createItem,
deleteItem,
deleteBudget,
}
}
// ---------------------------------------------------------------------------
// Standalone hook: single budget + items with categories
// ---------------------------------------------------------------------------
/**
* Fetches a single budget row and all its items joined with category data.
*
* @param id - The budget UUID
*/
export function useBudgetDetail(id: string) {
const budgetQuery = useQuery({
queryKey: budgetKey(id),
queryFn: async () => {
const { data, error } = await supabase
.from("budgets")
.select("*")
.eq("id", id)
.single()
if (error) throw error
return data as Budget
},
enabled: Boolean(id),
})
const itemsQuery = useQuery({
queryKey: budgetItemsKey(id),
queryFn: async () => {
const { data, error } = await supabase
.from("budget_items")
.select("*, category:categories(*)")
.eq("budget_id", id)
.order("created_at")
if (error) throw error
return data as BudgetItem[]
},
enabled: Boolean(id),
})
return {
budget: budgetQuery.data ?? null,
items: itemsQuery.data ?? [],
loading: budgetQuery.isLoading || itemsQuery.isLoading,
}
}

View File

@@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { Category, CategoryType } from "@/lib/types"
export function useCategories() {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: ["categories"],
queryFn: async () => {
const { data, error } = await supabase
.from("categories")
.select("*")
.order("type")
.order("sort_order")
if (error) throw error
return data as Category[]
},
})
const create = useMutation({
mutationFn: async (category: {
name: string
type: CategoryType
icon?: string
}) => {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Not authenticated")
const { data, error } = await supabase
.from("categories")
.insert({ ...category, user_id: user.id })
.select()
.single()
if (error) throw error
return data as Category
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
})
const update = useMutation({
mutationFn: async ({
id,
...updates
}: Partial<Category> & { id: string }) => {
const { data, error } = await supabase
.from("categories")
.update(updates)
.eq("id", id)
.select()
.single()
if (error) throw error
return data as Category
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
})
const remove = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("categories").delete().eq("id", id)
if (error) throw error
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
})
return {
categories: query.data ?? [],
loading: query.isLoading,
create,
update,
remove,
}
}

137
src/hooks/useQuickAdd.ts Normal file
View File

@@ -0,0 +1,137 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { QuickAddItem } from "@/lib/types"
// ---------------------------------------------------------------------------
// Query key
// ---------------------------------------------------------------------------
const QUICK_ADD_KEY = ["quick-add"] as const
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* TanStack Query hook for managing the user's quick-add item library.
*
* Provides:
* - `items` — ordered list of QuickAddItem rows
* - `loading` — true while the initial fetch is in-flight
* - `create` — mutation to add a new item (name, optional icon)
* - `update` — mutation to rename / change the icon of an existing item
* - `remove` — mutation to delete an item by id
*/
export function useQuickAdd() {
const queryClient = useQueryClient()
// ------------------------------------------------------------------
// Query: list all quick-add items for the current user, by sort_order
// ------------------------------------------------------------------
const query = useQuery({
queryKey: QUICK_ADD_KEY,
queryFn: async () => {
const { data, error } = await supabase
.from("quick_add_items")
.select("*")
.order("sort_order")
if (error) throw error
return data as QuickAddItem[]
},
})
// ------------------------------------------------------------------
// Mutation: create a new quick-add item
// ------------------------------------------------------------------
const create = useMutation({
mutationFn: async ({
name,
icon,
}: {
name: string
icon?: string | null
}) => {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error("Not authenticated")
// Determine next sort_order as max + 1 (or 0 for the first item)
const currentItems = queryClient.getQueryData<QuickAddItem[]>(QUICK_ADD_KEY) ?? []
const nextOrder =
currentItems.length > 0
? Math.max(...currentItems.map((i) => i.sort_order)) + 1
: 0
const { data, error } = await supabase
.from("quick_add_items")
.insert({
user_id: user.id,
name,
icon: icon ?? null,
sort_order: nextOrder,
})
.select()
.single()
if (error) throw error
return data as QuickAddItem
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUICK_ADD_KEY }),
})
// ------------------------------------------------------------------
// Mutation: update name and/or icon of an existing item
// ------------------------------------------------------------------
const update = useMutation({
mutationFn: async ({
id,
name,
icon,
}: {
id: string
name: string
icon: string | null
}) => {
const { data, error } = await supabase
.from("quick_add_items")
.update({ name, icon })
.eq("id", id)
.select()
.single()
if (error) throw error
return data as QuickAddItem
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUICK_ADD_KEY }),
})
// ------------------------------------------------------------------
// Mutation: delete a quick-add item by id
// ------------------------------------------------------------------
const remove = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase
.from("quick_add_items")
.delete()
.eq("id", id)
if (error) throw error
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUICK_ADD_KEY }),
})
// ------------------------------------------------------------------
// Exposed API
// ------------------------------------------------------------------
return {
/** All quick-add items for the current user, ordered by sort_order. */
items: query.data ?? [],
loading: query.isLoading,
create,
update,
remove,
}
}

207
src/hooks/useTemplate.ts Normal file
View File

@@ -0,0 +1,207 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { supabase } from "@/lib/supabase"
import type { Template, TemplateItem } from "@/lib/types"
// ---------------------------------------------------------------------------
// Query keys
// ---------------------------------------------------------------------------
const TEMPLATE_KEY = ["template"] as const
const TEMPLATE_ITEMS_KEY = ["template-items"] as const
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function getOrCreateTemplate(): Promise<Template> {
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) throw new Error("Not authenticated")
// Try to load the user's existing template first.
const { data: existing, error: fetchError } = await supabase
.from("templates")
.select("*")
.eq("user_id", user.id)
.maybeSingle()
if (fetchError) throw fetchError
if (existing) return existing as Template
// No template yet — create one.
const { data: created, error: createError } = await supabase
.from("templates")
.insert({ user_id: user.id, name: "My Monthly Template" })
.select()
.single()
if (createError) throw createError
return created as Template
}
// ---------------------------------------------------------------------------
// Main hook
// ---------------------------------------------------------------------------
export function useTemplate() {
const queryClient = useQueryClient()
// ------------------------------------------------------------------
// Query: template row (auto-created if absent)
// ------------------------------------------------------------------
const templateQuery = useQuery({
queryKey: TEMPLATE_KEY,
queryFn: getOrCreateTemplate,
})
const templateId = templateQuery.data?.id
// ------------------------------------------------------------------
// Query: template items with joined category data
// ------------------------------------------------------------------
const itemsQuery = useQuery({
queryKey: TEMPLATE_ITEMS_KEY,
queryFn: async () => {
if (!templateId) return []
const { data, error } = await supabase
.from("template_items")
.select("*, category:categories(*)")
.eq("template_id", templateId)
.order("sort_order")
if (error) throw error
return data as TemplateItem[]
},
enabled: Boolean(templateId),
})
// ------------------------------------------------------------------
// Mutation: update template name
// ------------------------------------------------------------------
const updateName = useMutation({
mutationFn: async (name: string) => {
if (!templateId) throw new Error("Template not loaded")
const { data, error } = await supabase
.from("templates")
.update({ name })
.eq("id", templateId)
.select()
.single()
if (error) throw error
return data as Template
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: TEMPLATE_KEY }),
})
// ------------------------------------------------------------------
// Mutation: create template item
// ------------------------------------------------------------------
const createItem = useMutation({
mutationFn: async (payload: {
category_id: string
item_tier: TemplateItem["item_tier"]
budgeted_amount: number
}) => {
if (!templateId) throw new Error("Template not loaded")
// Determine the next sort_order value from the current items list.
const currentItems = queryClient.getQueryData<TemplateItem[]>(TEMPLATE_ITEMS_KEY) ?? []
const nextOrder =
currentItems.length > 0
? Math.max(...currentItems.map((i) => i.sort_order)) + 1
: 0
const { data, error } = await supabase
.from("template_items")
.insert({ ...payload, template_id: templateId, sort_order: nextOrder })
.select("*, category:categories(*)")
.single()
if (error) throw error
return data as TemplateItem
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: TEMPLATE_ITEMS_KEY }),
})
// ------------------------------------------------------------------
// Mutation: update template item
// ------------------------------------------------------------------
const updateItem = useMutation({
mutationFn: async ({
id,
...updates
}: { id: string } & Partial<
Pick<TemplateItem, "item_tier" | "budgeted_amount" | "sort_order">
>) => {
const { data, error } = await supabase
.from("template_items")
.update(updates)
.eq("id", id)
.select("*, category:categories(*)")
.single()
if (error) throw error
return data as TemplateItem
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: TEMPLATE_ITEMS_KEY }),
})
// ------------------------------------------------------------------
// Mutation: delete template item
// ------------------------------------------------------------------
const deleteItem = useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase
.from("template_items")
.delete()
.eq("id", id)
if (error) throw error
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: TEMPLATE_ITEMS_KEY }),
})
// ------------------------------------------------------------------
// Mutation: reorder items (accepts ordered array of ids)
// ------------------------------------------------------------------
const reorderItems = useMutation({
mutationFn: async (orderedIds: string[]) => {
// Build an array of patch promises — one per item.
const patches = orderedIds.map((id, index) =>
supabase
.from("template_items")
.update({ sort_order: index })
.eq("id", id)
)
const results = await Promise.all(patches)
const firstError = results.find((r) => r.error)?.error
if (firstError) throw firstError
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: TEMPLATE_ITEMS_KEY }),
})
// ------------------------------------------------------------------
// Exposed API
// ------------------------------------------------------------------
return {
/** The single template row for the current user. */
template: templateQuery.data ?? null,
/** Template items sorted by sort_order, each with joined category data. */
items: itemsQuery.data ?? [],
loading: templateQuery.isLoading || itemsQuery.isLoading,
updateName,
createItem,
updateItem,
deleteItem,
reorderItems,
}
}

18
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from "i18next"
import { initReactI18next } from "react-i18next"
import en from "./en.json"
import de from "./de.json"
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
de: { translation: de },
},
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
})
export default i18n

12
src/lib/format.ts Normal file
View File

@@ -0,0 +1,12 @@
export function formatCurrency(
amount: number,
currency = "EUR",
locale = "de-DE"
): string {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}

19
src/lib/palette.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { CategoryType } from "@/lib/types"
export const categoryColors: Record<CategoryType, string> = {
income: "var(--color-income)",
bill: "var(--color-bill)",
variable_expense: "var(--color-variable-expense)",
debt: "var(--color-debt)",
saving: "var(--color-saving)",
investment: "var(--color-investment)",
}
export const categoryLabels: Record<CategoryType, { en: string; de: string }> = {
income: { en: "Income", de: "Einkommen" },
bill: { en: "Bills", de: "Rechnungen" },
variable_expense: { en: "Variable Expenses", de: "Variable Ausgaben" },
debt: { en: "Debts", de: "Schulden" },
saving: { en: "Savings", de: "Ersparnisse" },
investment: { en: "Investments", de: "Investitionen" },
}

10
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createClient } from "@supabase/supabase-js"
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY env vars")
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

83
src/lib/types.ts Normal file
View File

@@ -0,0 +1,83 @@
export type CategoryType =
| "income"
| "bill"
| "variable_expense"
| "debt"
| "saving"
| "investment"
export type ItemTier = "fixed" | "variable" | "one_off"
export interface Profile {
id: string
display_name: string | null
locale: string
currency: string
created_at: string
updated_at: string
}
export interface Category {
id: string
user_id: string
name: string
type: CategoryType
icon: string | null
sort_order: number
created_at: string
updated_at: string
}
export interface Template {
id: string
user_id: string
name: string
created_at: string
updated_at: string
}
export interface TemplateItem {
id: string
template_id: string
category_id: string
item_tier: Exclude<ItemTier, "one_off">
budgeted_amount: number
sort_order: number
created_at: string
updated_at: string
category?: Category
}
export interface Budget {
id: string
user_id: string
start_date: string
end_date: string
currency: string
carryover_amount: number
created_at: string
updated_at: string
}
export interface BudgetItem {
id: string
budget_id: string
category_id: string
budgeted_amount: number
actual_amount: number
item_tier: ItemTier
notes: string | null
created_at: string
updated_at: string
category?: Category
}
export interface QuickAddItem {
id: string
user_id: string
name: string
icon: string | null
sort_order: number
created_at: string
updated_at: string
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

31
src/main.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TooltipProvider } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner"
import "@/i18n"
import "@/index.css"
import App from "@/App"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
})
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<TooltipProvider>
<App />
<Toaster />
</TooltipProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
)

View File

@@ -0,0 +1,37 @@
-- Profiles table (extends Supabase auth.users)
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
display_name text,
locale text not null default 'en',
currency text not null default 'EUR',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
alter table profiles enable row level security;
create policy "Users can read own profile"
on profiles for select
using (id = auth.uid());
create policy "Users can update own profile"
on profiles for update
using (id = auth.uid())
with check (id = auth.uid());
create policy "Users can insert own profile"
on profiles for insert
with check (id = auth.uid());
-- Auto-create profile on user signup
create or replace function handle_new_user()
returns trigger as $$
begin
insert into profiles (id) values (new.id);
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function handle_new_user();

View File

@@ -0,0 +1,25 @@
-- Category type enum
create type category_type as enum (
'income', 'bill', 'variable_expense', 'debt', 'saving', 'investment'
);
-- Categories table
create table categories (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
type category_type not null,
icon text,
sort_order int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index categories_user_id_idx on categories(user_id);
alter table categories enable row level security;
create policy "Users can CRUD own categories"
on categories for all
using (user_id = auth.uid())
with check (user_id = auth.uid());

View File

@@ -0,0 +1,46 @@
-- Item tier enum
create type item_tier as enum ('fixed', 'variable', 'one_off');
-- Templates table (one per user)
create table templates (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null default 'My Template',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(user_id)
);
create index templates_user_id_idx on templates(user_id);
alter table templates enable row level security;
create policy "Users can CRUD own template"
on templates for all
using (user_id = auth.uid())
with check (user_id = auth.uid());
-- Template items table
create table template_items (
id uuid primary key default gen_random_uuid(),
template_id uuid not null references templates(id) on delete cascade,
category_id uuid not null references categories(id) on delete restrict,
item_tier item_tier not null check (item_tier in ('fixed', 'variable')),
budgeted_amount numeric(12,2) not null default 0,
sort_order int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index template_items_template_id_idx on template_items(template_id);
alter table template_items enable row level security;
create policy "Users can CRUD own template items"
on template_items for all
using (
template_id in (select id from templates where user_id = auth.uid())
)
with check (
template_id in (select id from templates where user_id = auth.uid())
);

View File

@@ -0,0 +1,46 @@
-- Budgets table
create table budgets (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
start_date date not null,
end_date date not null,
currency text not null default 'EUR',
carryover_amount numeric(12,2) not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index budgets_user_id_idx on budgets(user_id);
alter table budgets enable row level security;
create policy "Users can CRUD own budgets"
on budgets for all
using (user_id = auth.uid())
with check (user_id = auth.uid());
-- Budget items table
create table budget_items (
id uuid primary key default gen_random_uuid(),
budget_id uuid not null references budgets(id) on delete cascade,
category_id uuid not null references categories(id) on delete restrict,
budgeted_amount numeric(12,2) not null default 0,
actual_amount numeric(12,2) not null default 0,
item_tier item_tier not null default 'fixed',
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index budget_items_budget_id_idx on budget_items(budget_id);
alter table budget_items enable row level security;
create policy "Users can CRUD own budget items"
on budget_items for all
using (
budget_id in (select id from budgets where user_id = auth.uid())
)
with check (
budget_id in (select id from budgets where user_id = auth.uid())
);

View File

@@ -0,0 +1,19 @@
-- Quick-add items table
create table quick_add_items (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
name text not null,
icon text,
sort_order int not null default 0,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index quick_add_items_user_id_idx on quick_add_items(user_id);
alter table quick_add_items enable row level security;
create policy "Users can CRUD own quick-add items"
on quick_add_items for all
using (user_id = auth.uid())
with check (user_id = auth.uid());

32
tsconfig.app.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import path from "path"
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})