diff --git a/CLAUDE.md b/CLAUDE.md index 5d17bd31..f618dd10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,7 +94,7 @@ Layered architecture: ### Frontend (`frontend/src/`) -- **`pages/`** - 25 page components (24 feature pages + `MorePage` for phone grid nav), all lazy-loaded via `React.lazy` for code splitting. Pages import directly (no barrel re-export). **Structure convention**: multi-file pages use kebab-case directories (`bill-calendar/`, `year-in-review/`, `tax-planning/`, `trends-forecasts/`, `comparison/`, `goals/`, `income-expense-flow/`, `settings/`, `subscription-tracker/`) -- each containing `PageName.tsx` + `use.ts` + `types.ts` + `*utils.ts` + `components/` subfolder. Single-file pages use PascalCase (`DashboardPage.tsx`, `BudgetPage.tsx`, etc.). Settings uses `sections/` (instead of `components/`) because "section" is the domain term. `settings/sectionPrimitives.tsx` provides shared Section/FieldLabel/FieldHint primitives. See [`docs/PAGES.md`](docs/PAGES.md) for a data-focused catalog of what each page shows. +- **`pages/`** - 25 page components (24 feature pages + `MorePage` for phone grid nav), all lazy-loaded via `React.lazy` for code splitting. Pages import directly (no barrel re-export). **Structure convention**: multi-file pages use kebab-case directories (`bill-calendar/`, `year-in-review/`, `tax-planning/`, `trends-forecasts/`, `comparison/`, `goals/`, `income-expense-flow/`, `settings/`, `subscription-tracker/`) -- each containing `PageName.tsx` + `use.ts` + `types.ts` + `*utils.ts` + `components/` subfolder. Single-file pages use PascalCase (`DashboardPage.tsx`, `BudgetPage.tsx`, etc.). Settings uses `sections/` (instead of `components/`) because "section" is the domain term. `settings/sectionPrimitives.tsx` provides shared Section/FieldLabel/FieldHint primitives. See [`docs/HANDBOOK.md`](docs/HANDBOOK.md) for the full card-by-card walkthrough of every page + setting (formula + data source per metric), or [`docs/PAGES.md`](docs/PAGES.md) for the shorter data-focused catalog. - **`components/`** - Organized by domain: `analytics/` (chart components including `CategoryBreakdown` for shared category treemaps), `chat/` (ChatWidget, ChatPanel, ChatMessage, useChat hook for AI chatbot), `layout/` (AppLayout, Sidebar with ProfileModal), `shared/` (reusable components like MetricCard, ProgressBar, Sparkline, AnalyticsTimeFilter, ProtectedRoute, ProfileModal, ChunkErrorBoundary, EmptyState, LoadingSkeleton, QuickInsights), `transactions/`, `upload/`, `ui/` (base primitives including ChartContainer, PageContainer, PageHeader, Spinner, DataTable, ConfirmDialog, CollapsibleSection, plus chartDefaults helpers `referenceLine`/`currencyTooltipFormatter`). - **`hooks/`** - Custom React hooks. `useAnalyticsTimeFilter` encapsulates time-filter state (view mode, date range, FY) shared across all analytics pages. `useChartDimensions` provides responsive chart sizing. `hooks/api/` contains TanStack Query hooks for API calls, configured with `staleTime: Infinity` and `gcTime: 1 hour`. - **`services/api/`** - Axios-based API client. Axios interceptor auto-attaches JWT `Authorization` header. `aiConfig.ts` handles AI provider configuration CRUD. diff --git a/README.md b/README.md index b31efc49..62730c25 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ npx -y @mermaid-js/mermaid-cli -i docs/images/.mmd -o docs/images/.s ## Pages -Every page focuses on a specific question you'd ask about your money. For the **detailed data catalog** — what each page shows, where the numbers come from, and what decisions it helps you make — see **[docs/PAGES.md](docs/PAGES.md)**. +Every page focuses on a specific question you'd ask about your money. The **[Complete Handbook](docs/HANDBOOK.md)** walks through every page card-by-card — what each metric, chart, and setting shows, with the exact formula and data source for each. For the shorter **data catalog** version, see **[docs/PAGES.md](docs/PAGES.md)**. | Page | Answers | | --- | --- | diff --git a/docs/HANDBOOK.md b/docs/HANDBOOK.md new file mode 100644 index 00000000..5070d762 --- /dev/null +++ b/docs/HANDBOOK.md @@ -0,0 +1,1839 @@ +# Ledger Sync — Complete Handbook + +> A page-by-page reference for everything in Ledger Sync: what each screen shows, what every card / chart / metric means, how each number is computed, and what every setting controls. +> +> Generated from the live source (`frontend/src/pages/**`) — accurate as of **v2.17.0** (2026-06-27). For the terse developer data-catalog see [PAGES.md](PAGES.md); for the API see [API.md](API.md); for the schema see [DATABASE.md](DATABASE.md). + +## How to read this + +Each page section has: + +- **Route** — the URL, and **Reads from** — the backend endpoints / hooks that feed it. +- **What's on the page** — every card, chart, table, toggle, and form, with *Computed:* showing the formula and the inputs (including any Settings that feed it). +- **Controls & notes** — the time filter, toggles, deep-links, empty states, and preference coupling. + +**A note on data consistency.** As of v2.17, most analytics pages read pre-aggregated values from the backend (`/api/calculations/*`, `/api/analytics/v2/*`) rather than recomputing over the full transaction ledger in the browser. Each section's **Reads from** line names its source, so the same metric (e.g. total income, a category total) is computed once on the server and shown identically across pages. A handful of pages that mix user-preference or projection math (Tax, GST, net-worth/XIRR projections, the CFP health score) still compute client-side — these are flagged in their **Controls & notes**. + +## Contents + +**Getting started** + +- [Home](#home) +- [Upload & Sync](#upload--sync) +- [Transactions](#transactions) + +**Overview** + +- [Dashboard](#dashboard) + +**Spending & income** + +- [Spending Analysis](#spending-analysis) +- [Income Analysis](#income-analysis) +- [Income/Expense Flow (Sankey)](#incomeexpense-flow-sankey) +- [Budgets](#budgets) + +**Trends & comparison** + +- [Trends & Forecasts](#trends--forecasts) +- [Period Comparison](#period-comparison) +- [Year in Review](#year-in-review) + +**Net worth & investments** + +- [Net Worth](#net-worth) +- [Investment Analytics](#investment-analytics) +- [Mutual Fund / SIP Projection](#mutual-fund--sip-projection) +- [Returns Analysis](#returns-analysis) + +**Tax** + +- [Tax Planning](#tax-planning) +- [GST Analysis](#gst-analysis) + +**Planning** + +- [FIRE Calculator](#fire-calculator) +- [Goals](#goals) + +**Recurring & alerts** + +- [Subscription Tracker](#subscription-tracker) +- [Bill Calendar](#bill-calendar) +- [Anomaly Review](#anomaly-review) + +**Configuration** + +- [Settings](#settings) + +--- + +## Home +**Route:** `/` · **Reads from:** OAuth provider configuration (no persisted data endpoints) + +Public landing page introducing Ledger Sync to authenticated and unauthenticated users with clear CTAs for account creation, demo access, or dashboard navigation. + +### What's on the page + +**Header** (navigation) — Fixed top bar with: + - **Ledger Sync logo + app name** — Link back to home (`/`) + - **Sign In / Dashboard button** — Conditional: shows "Sign In" for unauthenticated users (opens AuthModal); shows "Hi, [firstname]!" for authenticated users, linking to `/dashboard` + +**Hero section** — Primary banner with: + - **"Personal Finance Made Simple"** (label) — Decorative badge with Sparkles icon + - **"Take Control of Your Finances"** (headline) — Primary call-to-action messaging + - **Body copy** — "Ledger Sync is your all-in-one financial dashboard..." describing the product value proposition + - **Get Started Free / Go to Dashboard button** — Conditional: "Get Started Free" for unauthenticated users (opens AuthModal); "Go to Dashboard" for authenticated users (navigates to `/dashboard`) + - **Try Demo button** — Unauthenticated only; invokes `enterDemoMode()` which seeds demo data into TanStack Query cache, sets fake auth tokens, hydrates preferences store with demo preferences (fiscal year April-March, demo user profile), and navigates to `/dashboard` + - **Learn More button** — Anchor link to `#features` section (FeaturesSection below) + - **Highlight badges** (6 cards) — Static feature callouts: "Works with Money Manager Pro exports," "Smart duplicate detection," "Secure, private data storage," "India-focused tax calculations," "Beautiful dark-mode UI," "Multi-account support" — each with green CheckCircle2 icon + +**"What is Ledger Sync?" section** — Two-column layout: + - Left side: feature descriptions (3 cards): + - **"Excel Import"** — Describes Money Manager Pro export import + duplicate detection + - **"Smart Analytics"** — Describes 50/30/20 budget analysis, spending trends, income patterns, investment returns + - **"India-Focused"** — Describes fiscal year (April-March) support, INR formatting, tax planning tools + - Right side: **mockup card** displaying sample financial metrics (not live data; for illustration): + - **Net Worth** — Example value "₹24,85,000" with change badge "+12.4%" (green) + - **Income** — Example "₹1,25,000" + - **Expenses** — Example "₹68,500" + - **Savings** — Example "₹56,500" + - **Investments** — Example "₹12,40,000" + +**"Everything You Need" (Features) section** — 4-card grid (`id="features"` anchor target): + - **Smart Analytics** (BarChart3 icon, blue) — "50/30/20 budget tracking, spending patterns, and income analysis with beautiful visualizations" + - **Investment Tracking** (TrendingUp icon, green) — "Track FD/Bonds, Mutual Funds, PPF/EPF, and Stocks with returns analysis" + - **Tax Planning** (Shield icon, orange) — "India FY-based tax insights, deduction tracking, and regime comparison" + - **Instant Sync** (Zap icon, purple) — "Upload Excel files with automatic duplicate detection and smart reconciliation" + +**"Ready to Take Control?" CTA section** — Repeats primary calls-to-action: + - **Create Free Account / Upload Your Data button** — Conditional: "Create Free Account" for unauthenticated users (opens AuthModal); "Upload Your Data" for authenticated users (navigates to `/dashboard`) + - **Try Demo button** — Unauthenticated only (repeats hero behavior) + +**Footer** — Static: "Made with ❤️ for better financial management" + +### Controls & notes + +**Authentication flow:** + - Unauthenticated users see Sign In button and Try Demo option + - Authenticated users (detected via `useAuthStore().isAuthenticated`) see user's first name and direct Dashboard link + - AuthModal fetches available OAuth providers (Google, GitHub) via `authApi.getOAuthProviders()` when opened; supports configurable OAuth with dynamic provider loading + - Redirects to OAuth authorize URL, then expects callback to `/auth/callback/:provider` + +**Demo mode:** + - Clicking "Try Demo" calls `enterDemoMode(queryClient, navigate)` which: + - Sets demo flag in `useDemoStore` + - Logs in fake demo user (`id: -1, email: demo@ledger-sync.app`) + - Seeds React Query cache with sample transactions and analytics data + - Hydrates `usePreferencesStore` with generated demo preferences (fiscal year April-March) + - Navigates to `/dashboard` + - Demo mode is ephemeral—local store state only, no backend persistence + +**Navigation routing:** + - "Get Started" / "Create Free Account" → Opens AuthModal; post-login navigates to `/dashboard` + - "Go to Dashboard" / "Upload Your Data" → Direct link to `/dashboard` (protected route via ProtectedRoute wrapper) + - "Try Demo" → Enters demo mode, navigates to `/dashboard` + - Authenticated users in header can click first name + arrow → `/dashboard` + +**No deep-links or URL parameters:** + - Home page is purely informational; no query string filters, category parameters, or time selection + - All configuration is set server-side via OAuth provider config or via preferences store on authenticated routes + +**Empty state:** + - No empty state; page is always fully populated with marketing content + - Conditional rendering based on `isAuthenticated` flag only + +**Preferences / Settings coupling:** + - Home page reads only `useAuthStore().isAuthenticated` and `user.full_name` + - Demo mode auto-generates preferences (no user interaction) + - No preference toggles or settings controls on this page; those live in `/settings` + +**No server analytics endpoints called:** + - HomePage is static marketing; makes zero calls to `/api/analytics/*`, `/api/calculations/*`, or `/api/transactions/*` + - Only fetches OAuth provider config on AuthModal open + - Demo mode seeds local cache via `seedDemoCache()` utility (client-side generation) + +--- + +## Upload & Sync +**Route:** `/upload` · **Reads from:** `/api/upload`, `/api/analytics/v2/refresh` + +Import Excel or CSV transaction files with automatic duplicate detection and smart sync. The page parses data client-side, uploads to the backend, then rebuilds analytics on the server. + +### What's on the page + +**Smart Sync badge** (label) — Top-left indicator showing "Smart Sync" feature positioning. + +**Upload & Sync heading** (page title) — Main heading: "Upload & Sync". + +**Import description** (text) — One-line value prop: "Import your Excel or CSV transactions. We'll automatically detect changes and sync your data." + +**Feature bullets** (list) — Three feature claims: +- *Auto-detect duplicates* — Shown with CheckCircle2 icon +- *Smart sync* — Shown with RefreshCw icon +- *.xlsx, .xls & .csv* — Shown with FileSpreadsheet icon; *supported:* file formats accepted by the dropzone (multipart/spreadsheetml, application/vnd.ms-excel, text/csv only) + +**Dropzone** (form input) — Large drag-and-drop area accepting `.xlsx`, `.xls`, or `.csv` files. Displays upload icon with text "Drop Excel or CSV file here" or "Drop your file here" on drag. Shows file name and upload progress during phases. States: +- *idle:* border-white/20, hover-primary +- *dragging:* border-primary, bg-primary/20, scale-[1.02] +- *busy:* opacity-50, cursor-not-allowed; displays animated spinner and PHASE_LABELS text +- *computed:* disables when `isBusy === true` (any phase running) + +**Upload phases spinner and label** (progress indicator) — Displays during upload; animates through four phases: +- *Parsing file...* — Client-side SheetJS parsing of .xlsx/.xls/.csv +- *Uploading data...* — POST to `/api/upload` with `file_name`, `file_hash` (SHA-256), `rows` array, `force` flag +- *Processing transactions...* — Server-side transaction insertion/update/deletion +- *Computing analytics...* — POST to `/api/analytics/v2/refresh` to rebuild all derived metrics; *timeout:* 120 seconds per request + +**File name display** (text, monospace) — Shows currently selected/uploading file name during upload. + +**File conflict error** (alert box, yellow) — Appears when server returns "already imported" error. Shows filename and message "was imported before. Re-upload to sync changes." +- *conditional display:* only shows when `conflictError !== null` +- *computed:* set when `getApiErrorMessage(error).includes('already imported') || rawMessage.includes('Use --force')` + +**Force Reupload button** (primary control) — Yellow button in conflict error state; re-uploads same file with `force: true` flag, bypassing duplicate check. Disabled during upload phase (`isBusy === true`). + +**Expected Format section** (documentation) — Shows sample Excel/CSV table structure with seven required/recommended columns: + +**Sample data table** (visual reference) — Rows demonstrate realistic transactions: +- **Date** (text, monospace, required) — Parsed as YYYY-MM-DD; accepts Excel serial dates, ISO 8601, DD/MM/YYYY, or text dates like "15-Mar-2024" +- **Account** (text, required) — Account name (e.g., "HDFC Bank", "ICICI Card"); flexible column names accept "Accounts" or "Account" +- **Category** (text, required) — Category name (e.g., "Salary", "Food", "Shopping"); flexible names accept "Category" only +- **Subcategory** (text, optional) — Hierarchical category (e.g., "Monthly", "Groceries", "Electronics"); flexible names accept "Subcategory", "Sub Category" +- **Type** (badge, required) — One of `Income`, `Expense`, `Transfer-In`, `Transfer-Out`; case-insensitive; flexible column names accept "Income/Expense", "Type", "Transaction Type"; *validation:* must match VALID_TYPES set (income, expense, exp., expenses, transfer, transfer-in, transfer in, transfer-out, transfer out) +- **Amount** (number, right-aligned, monospace, required) — Numeric amount; accepts comma-separated thousands (e.g., "3,500" or 3500); parsed as absolute value and rounded to 2 decimals +- **Note** (text, optional, tertiary) — Optional description; flexible column names accept "Note", "Notes", "Description" + +Type badges render with semantic color styles from `TYPE_STYLES` (getSemanticBadgeClass lookup). + +**Format tip footer** (informational) — Text: "Column names are flexible — 'Period' or 'Date' both work. Export from Money Manager Pro for best results." + +### Controls & notes + +**Column name flexibility** — Parser uses `COLUMN_MAPPINGS` to match flexible headers: +- `date`: accepts Period, Date, date, period +- `account`: accepts Accounts, Account, account, accounts +- `category`: accepts Category, category +- `amount`: accepts Amount / INR, Amount, amount, Amount/INR +- `type`: accepts Income/Expense, Type, type, Transaction Type +- `note`: accepts Note, note, Notes, notes, Description +- `subcategory`: accepts Subcategory, subcategory, Sub Category +- `currency`: accepts Currency, currency (optional; defaults to INR if missing) + +Required columns: date, account, category, amount, type. Missing required columns trigger FileParseError. + +**File parsing** — Client-side using SheetJS (lazy-loaded): +1. Reads first sheet only +2. Extracts headers and rows +3. Validates required columns +4. Parses each field with type coercion +5. Computes SHA-256 file hash for duplicate detection +6. Returns structured `ParsedTransaction[]` array + +**Date parsing** (timezone-stable): +- Excel serial date numbers → UTC components (via EXCEL_EPOCH = 1899-12-30) +- ISO date (YYYY-MM-DD) → taken verbatim +- Numeric DD/MM/YYYY → India-first parsing (not MM/DD) +- Text dates (e.g., "15-Mar-2024") → parsed as LOCAL midnight, components extracted (avoids timezone shift for +UTC offset regions like India) +- Invalid dates → FileParseError + +**Amount parsing**: +- Numeric values passed through; string values have commas stripped +- Result rounded to 2 decimals (Math.round(value * 100) / 100) +- Always absolute value (Math.abs) + +**Upload payload** — POST `/api/upload`: +``` +{ + file_name: string, + file_hash: string (SHA-256 hex), + rows: ParsedTransaction[], + force?: boolean +} +``` +*Timeout:* 120 seconds (UPLOAD_TIMEOUT_MS) + +**Upload response** — Returns `UploadResponse`: +``` +{ + success: boolean, + message: string, + stats: { + processed?: number, + inserted: number, + updated: number, + deleted: number, + unchanged: number + }, + file_name: string +} +``` + +**Success toast** — Shows: "{rows.length} rows parsed. {inserted} inserted, {updated} updated, {deleted} deleted, {unchanged} skipped (duplicates)." + +**Conflict handling** — When file already imported: +1. Error message includes "already imported" or "Use --force" +2. `setConflictError({ parsed, message })` stores parsed data + error +3. User sees yellow alert with "Force Reupload" button +4. Re-upload with `force: true` bypasses duplicate check + +**Duplicate detection** — File hash (SHA-256) compared server-side; if file previously imported, returns conflict error unless `force: true`. + +**Analytics rebuild** — After successful upload, calls `/api/analytics/v2/refresh` (separate request for serverless reliability): +- If analytics refresh fails, shows warning toast: "Analytics refresh failed — dashboard may show stale data until next upload." +- Page still considers upload successful; analytics rebuild is asynchronous best-effort + +**Post-upload cleanup** — On success: +1. Clears all React Query cache (`queryClient.clear()`) +2. Prefetches core data (`prefetchCoreData()`) +3. Clears local state: `setPhase(null)`, `setSelectedFile(null)`, `setConflictError(null)` + +**Empty state** — If file contains zero data rows → FileParseError: "File contains no data rows" + +**Error states**: +- *Parse errors* — Client-side validation (missing columns, invalid types, empty values) → toast with 6s duration +- *Network errors* (ECONNABORTED, ERR_NETWORK) → custom timeout/connectivity messages +- *Server timeout* (FUNCTION_INVOCATION_TIMEOUT) → "Server took too long to process. Please try again in a moment." +- *Other upload errors* → generic error toast with server message + +**Demo guard** — Page respects demo mode; `useDemoGuard()` prevents file uploads in demo accounts; toast warning shown if attempted + +**Maximum files** — Dropzone accepts `maxFiles: 1` only + +--- + +## Transactions +**Route:** `/transactions` · **Reads from:** `/api/transactions/facets` (categories, accounts, type counts), `/api/transactions/search` (paginated transactions with filters) + +Browse and search your transaction history with server-side filtering, sorting, and pagination. All analytics are computed on the backend; the page displays dropdown facets and per-type counts without fetching the full ledger. + +### What's on the page + +- **Total Transactions** (metric) — count of all transactions in the ledger; *computed:* `facets.total_count` from `/api/transactions/facets` +- **Income** (metric) — count of income-type transactions; *computed:* `facets.income_count` +- **Expense** (metric) — count of expense-type transactions; *computed:* `facets.expense_count` +- **Transfer** (metric) — count of transfer-type transactions; *computed:* `facets.transfer_count` +- **Search box** (form) — full-text search across note, category, and account; *debounced:* 300ms, updates `filters.query` and triggers new `/api/transactions/search` request with `?query=` +- **Filters button** (toggle) — expands/collapses the advanced filter panel via `showAdvanced` state +- **Clear button** (control) — appears only when `hasActiveFilters` is true (any filter set or search query present); resets all filters to `{}` and query to empty string +- **Type filter** (dropdown) — options: "All Types", "Income", "Expense", "Transfer"; *sets:* `filters.type` (maps to `?type=` in request) +- **Category filter** (dropdown) — populated from `facets.categories`; *sets:* `filters.category` (maps to `?category=`) +- **Account filter** (dropdown) — populated from `facets.accounts`; *sets:* `filters.account` (maps to `?account=`) +- **Start Date** (input, date) — ISO date format; *sets:* `filters.start_date` (maps to `?start_date=` in request) +- **End Date** (input, date) — ISO date format; *sets:* `filters.end_date` (maps to `?end_date=`) +- **Min Amount** (input, number) — displayed in user's preferred currency symbol (from `usePreferencesStore().displayPreferences.currencySymbol`); *sets:* `filters.min_amount` (maps to `?min_amount=`) +- **Max Amount** (input, number) — displayed in user's preferred currency symbol; *sets:* `filters.max_amount` (maps to `?max_amount=`) +- **Export CSV button** (action) — calls `/api/transactions/export` with current filters; *state:* `isExporting` controls button disabled state and "Exporting..." label; downloads file named `transactions-.csv` +- **Transaction Table** (desktop view, table) — displays paginated rows with columns: + - **Date** — sortable, displays `formatDate(row.date, { month: 'short', day: '2-digit', year: 'numeric' })`; *computed:* derived from `row.original.date` + - **Type** — icon + label (TrendingUp for Income / green, TrendingDown for Expense / red, arrow for Transfer / blue); *computed:* `row.original.type` + - **Category** — main category with optional subcategory below in smaller text; *computed:* `row.original.category` and `row.original.subcategory` + - **Account** — account name; *computed:* `row.original.account` + - **Amount** — sortable, formatted currency with prefix (+/- for Income/Expense, none for Transfer); color-coded (green for Income, red for Expense, teal for Transfer); *computed:* `prefix + formatCurrency(Math.abs(row.original.amount))` where prefix is '+' for Income, '-' for Expense, '' for Transfer + - **Note** — truncated to 120px on base/tablet, 200px on lg+; shows '-' if empty; *computed:* `row.original.note || '-'` +- **Transaction Cards** (mobile view, card list) — grouped by date with day header showing formatted date and daily total (sum of all transactions that day, accounting for +/- per type); each card shows category, subcategory, amount with color/prefix, account, and note +- **Pagination controls** — shows "Showing X to Y of Z transactions" and: + - Items per page dropdown: options 10, 25, 50, 100; *default:* 10; *sets:* `itemsPerPage` and resets `currentPage` to 1 + - Page number buttons: displays up to 5 page numbers, always centered around current page or edges; *computed:* if `totalPages <= 5` show 1–5, else if `currentPage <= 3` show 1–5, else if `currentPage >= totalPages - 2` show `totalPages - 4` to `totalPages`, else show `currentPage - 2` to `currentPage + 2` + - Previous/Next arrows: disabled when on first or last page respectively +- **Sorting state** — TanStack Table's `sorting` state; *default:* `[{ id: 'date', desc: true }]` (newest first); clicking **Date** or **Amount** headers toggles sort order; *server-side:* mapped to `?sort=&sort_order=` in `/api/transactions/search` request + +### Controls & notes + +- **Time filter coupling** — Start Date and End Date are optional; leaving both empty includes all transactions. Dates are passed as ISO strings (`YYYY-MM-DD`) to the backend. +- **Amount range** — Min Amount and Max Amount define an inclusive range; leaving both empty accepts all amounts. Non-numeric input is rejected (HTML5 `type="number"`). +- **Empty state** — when no transactions match filters, displays "No transactions found" with icon and hint to adjust filters; when loading, skeleton shimmer rows appear (6 rows on desktop, or 6 cards on mobile). +- **Pagination reset** — whenever filters, sorting, or items-per-page change, `currentPage` resets to 1. +- **Page scroll** — when user clicks a page number, `window.scrollTo({ top: 0, behavior: 'smooth' })` runs to scroll page header into view. +- **Server-side aggregation** — `/api/transactions/facets` endpoint returns pre-aggregated category/account lists and type counts (no full-ledger fetch into browser). Page uses `useTransactionFacets()` hook which caches indefinitely (`staleTime: Infinity`) until data is invalidated on upload. +- **Paginated results** — `/api/transactions/search` returns `{ data: Transaction[], total: number, limit: number, offset: number, has_more: boolean }`; `total` is the filtered count (not just the current page), so pagination bounds are correct even with filters applied. +- **Query param mapping** — filter state → URL params: + - `filters.query` → `?query=` + - `filters.type` → `?type=` + - `filters.category` → `?category=` + - `filters.account` → `?account=` + - `filters.start_date` → `?start_date=` + - `filters.end_date` → `?end_date=` + - `filters.min_amount` → `?min_amount=` + - `filters.max_amount` → `?max_amount=` + - `sorting[0].id` → `?sort_by=` (default 'date') + - `sorting[0].desc` → `?sort_order=` (default desc) + - `currentPage` + `itemsPerPage` → `?offset=<(page-1)*itemsPerPage>&limit=` +- **Currency symbol** — all amount inputs and labels are labeled with user's currency symbol from `usePreferencesStore().displayPreferences.currencySymbol` (e.g., "$", "₹", "€"). +- **CSV export** — on success, displays toast "Export successful!" and auto-downloads; on failure, displays toast "Export failed" with retry prompt. Button remains disabled while `isExporting` is true (prevents double-click). +- **Mobile grouping** — card view groups transactions by date (first 10 characters of ISO timestamp), calculates daily net total accounting for type (+Income, -Expense, ±Transfer), and displays as sticky day header above that day's transactions. +- **Search debounce** — search input uses custom `useDebounce(value, 300)` hook; debounced query updates filter only after 300ms of inactivity. First render skips emitting empty query (via `isFirstRender` ref). +- **Advanced filters animation** — expand/collapse panel uses Framer Motion `AnimatePresence` with fade + height transition; `showAdvanced` state controls visibility. +- **TanStack Table configuration** — `manualSorting: true` so sorting is handled by page state, not in-memory; `getCoreRowModel()` and `getSortedRowModel()` enable column rendering and client-side sort-icon toggling (actual data sort is server-side). + +--- + +## Dashboard +**Route:** `/dashboard` · **Reads from:** `/api/calculations/totals`, `/api/calculations/monthly-aggregation`, `/api/calculations/quick-insights`, `/api/calculations/category-breakdown` + `/api/analytics/v2/recurring-transactions` + +Real-time financial overview showing income, expenses, net savings, cash-based financial health, and emerging behavioral insights filtered by time period. + +### What's on the page + +**Page Header** (static) — "Dashboard" title with subtitle "Your financial overview at a glance". Includes AnalyticsTimeFilter in the action slot for time-range selection. + +**AnalyticsTimeFilter** (time-range selector) — Four view modes: *All Time*, *Yearly*, *Monthly*, *FY* (fiscal year). When *Yearly* is selected, prev/next buttons navigate year by year. When *Monthly*, navigate by month (YYYY-MM). When *FY*, navigate by fiscal year (e.g., "FY 2024-25"); fiscal year start month is read from `preferences.fiscal_year_start_month` (defaults to April/month 4). Navigation is bounded by `dataDateRange.minDate` and `.maxDate` (earliest/latest transaction dates). Current selections: `viewMode`, `currentYear`, `currentMonth`, `currentFY` — stored in component state and derived into an `analyticsDateRange` using `getAnalyticsDateRange()`, which feeds all downstream queries via `start_date` and `end_date` parameters. + +**Quick Insights** (metric cards section, full width) — Container labeled "Quick Insights". Calls `useTotals()`, `useQuickInsights()`, `useCategoryBreakdown()`, `useRecurringTransactions({ active_only: true, min_confidence: 0 })` for data. Renders two grids of insight cards (computed metrics, then behavioral "Fun Facts"). Widget visibility is user-customizable via localStorage key `'ledger-sync-visible-widgets'` (maps title strings to widget keys; defaults to all visible if 14+ are enabled). + +**Quick Insights — KPI row:** +- **Total Income** (metric card) — *computed:* sum of all Income transactions in filtered date range, from `totalsData.total_income`; *subtitle:* percent change vs. previous month's income, formatted via `fmtChange(momChanges.income, label)`. +- **Total Expenses** (metric card) — *computed:* absolute value of `totalsData.total_expenses`; *subtitle:* percent change vs. previous month's expenses. +- **Net Savings** (metric card) — *computed:* `totalsData.net_savings` (income minus expenses); *subtitle:* percent change vs. previous month's net savings, using `savingsPct()` which divides by `Math.abs(prev)` to handle sign flips correctly. +- **Savings Rate** (metric card) — *computed:* `(net_savings / total_income) * 100` clamped to 1 decimal place; *subtitle:* formatted as "X saved of Y" using `formatCurrency()` on numerator and denominator, or "No income recorded" if income is zero. +- **Age of Money** (metric card, conditional) — *computed:* `computeAgeOfMoney(filteredTransactions)` returns days; derived from all transaction dates in the filtered range, calculating the weighted average age; *subtitle:* health label ("Healthy buffer" ≥30 days, "Building runway" ≥15 days, else "Living paycheck to paycheck"). +- **Days of Buffering** (metric card, conditional) — *computed:* `computeDaysOfBuffering(liquidBalance, filteredTransactions)` where `liquidBalance = income - |expenses|`; returns how many days of spending the liquid balance covers at current burn rate; *subtitle:* "At current spending rate"; requires both transactions and finite totals. +- **Fixed Commitments** (metric card, conditional; only if `fixedCommitmentsMonthly > 0`) — *computed:* sum of confirmed active recurring expense amounts converted to monthly basis using `toMonthlyAmount(expected_amount, frequency)` for each item in `recurringItems` where `is_confirmed && type === 'Expense'`; *subtitle:* count of active recurring expenses. +- **Recurring Coverage** (metric card, conditional) — *computed:* `(fixedCommitmentsMonthly / monthlyIncome) * 100` where `monthlyIncome = totalIncome / monthsInRange`; *subtitle:* health label ("High fixed cost load" >50%, "Moderate" >30%, else "Low fixed costs"). + +**Quick Insights — Fun Facts row (behavioral metrics):** +- **Top Spending Category** (fact card) — *computed:* from `categoryData.categories`, sorted descending by total; returns category name and amount; *subtitle:* formatted amount; hides if no expense data. +- **Top Income Source** (fact card) — *computed:* from `insights.top_income_source` API response; name and amount; *subtitle:* formatted amount; hides if null. +- **Net Cashback Earned** (fact card) — *computed:* `insights.net_cashback` (from API) minus shared cashback; *subtitle:* "From X cashback transactions" using `insights.cashback_count`. +- **Biggest Transaction** (fact card) — *computed:* `insights.biggest_expense` (API); *value:* absolute amount; *subtitle:* category name. +- **Median Transaction** (fact card) — *computed:* `insights.median_expense` (API, server-side calculation); *subtitle:* "Few large purchases skew average up" if mean > median, else "Spending is fairly even". +- **Average Daily Spending** (fact card) — *computed:* `totalSpending / daysInRange` where `totalSpending = insights.total_spending` and `daysInRange` is computed from explicit filter dates or min/max dates returned by API; *subtitle:* "Over X days". +- **Weekend Spending** (fact card) — *computed:* `(weekendSpending / totalSpending) * 100`; *subtitle:* "X% weekends vs Y% weekdays" with absolute amounts for each; values from API (`insights.weekend_spending`, `.weekday_spending`). +- **Peak Spending Day** (fact card) — *computed:* day name from `insights.peak_day` (0=Sunday); *subtitle:* total amount on that day of week. +- **Monthly Burn Rate** (fact card) — *computed:* `totalSpending / monthsInRange` (avg monthly spend); *subtitle:* "Avg over X months" (X calculated from date range). +- **Spending Diversity** (fact card) — *computed:* count of unique categories and subcategories from expense breakdown; *value:* "X categories"; *subtitle:* "Across Y subcategories". +- **Avg Transaction Amount** (fact card) — *computed:* `insights.avg_expense` (server-side); *subtitle:* "Per transaction". +- **Internal Transfers** (fact card) — *computed:* `insights.total_transfers` and `insights.transfer_count`; *value:* formatted total; *subtitle:* "X transfers". +- **Income vs Expense Ratio** (fact card) — *computed:* `totalExpenseAbs / totalIncome` (ratio of expenses to income); *value:* formatted as "X.XXx"; *subtitle:* health label ("Great! Spending well below income" <0.7, "Spending close to income" <0.9, else "Spending nearly all income"). +- **Most Expensive Month** (fact card, conditional) — *computed:* from `insights.most_expensive_month` (API returns period as "YYYY-MM" and amount); *value:* formatted month label (e.g., "Dec 2024"); *subtitle:* formatted amount; only rendered if data exists. + +**Financial Health Score** (two-column grid or single full width on mobile) — Calls `useTransactions()` (all transactions) and `usePreferences()` to read `savings_goal_percent`, `fixed_expense_categories`. Displays two cards side by side: + +- **FinHealth Score card** — *computed:* weighted average of 8 health metrics (Spend Less Than Income, Essential Expense Ratio, Emergency Fund, Investment Regularity, Debt-to-Income, Debt Trend, Savings Consistency, Income Stability) using 3+ months of transaction history; *value:* single numeric score (0–100); *subtitle:* "Last X months" where X is `monthsAnalyzed`; includes radar chart showing each metric's score and grid of metric cards below, each with 0–100 bar and target description; overall status color (green/orange/red) based on score tier; note says "Financial Health Network framework". + +- **CFP Ratios card** — *computed:* Certified Financial Planner ratio composite score derived from income, expenses, essential expenses, debt, and net savings; *value:* numeric score; *subtitle:* "Last X months"; displays sub-ratios (Emergency Fund Ratio, Debt-to-Income Ratio, Investment Ratio, Savings Rate, Debt Payoff Plan, Essential Expense Ratio) in hierarchical view. + +**Income Sources** (left pie chart card, two-column grid on desktop) — Labeled "Income Sources" with wallet icon. If data exists: +- **Pie chart** (center label, clickable slices) — *computed:* `incomeChartData` from `calculateIncomeByCategoryBreakdown(filteredTransactions)` aggregated by category, filtered to include only positive values, sorted descending by amount; center shows `incomeTotal` formatted short (e.g., "$50K") with label "Total"; *interaction:* clicking a slice navigates to `/income?category=`. +- **Legend below chart** (list of category rows) — Each row shows colored dot, category name, and formatted amount; includes separator and final "Total" row with bold green amount plus optional "Cashbacks Earned" row (teal text) if `cashbacksTotal > 0`. + +If no data: empty state with wallet icon, title "No income data available", description "Configure income categories in Settings.", action link to `/settings`. + +**Expense Sources** (right pie chart card) — Labeled "Expense Sources" with credit-card icon. If data exists: +- **Pie chart** (identical structure to Income) — *computed:* `expenseChartData` from `calculateExpenseByCategoryBreakdown(filteredTransactions)` aggregated by category, filtered and sorted like income; center shows `expenseTotal` formatted short with label "Total"; *interaction:* clicking a slice navigates to `/spending?category=`. +- **Legend** (category list) — Same structure as income, ending with "Total" row showing bold red amount. + +If no data: empty state with credit-card icon, title "No expense data available", description "Upload transactions to see your expense breakdown.", action link to `/upload`. + +### Controls & notes + +**Time Filter State Management** — `viewMode`, `currentYear`, `currentMonth`, `currentFY` are stored in `DashboardPage` component state via `useDashboardMetrics()` hook. Setters wrap a `markInteracted()` function to track whether the user has manually adjusted the filter (preventing auto-reset to fiscal year start month when preferences load). Initial defaults: `viewMode` from `displayPreferences.defaultTimeRange` or 'all_time'; `currentYear` and `currentMonth` set to current calendar date; `currentFY` derived from `getCurrentFY(fiscalYearStartMonth)`, then synced if preferences arrive and user hasn't interacted. + +**Date Range Derivation** — The `analyticsDateRange` (start_date/end_date) is computed from `viewMode` + time selectors via `getAnalyticsDateRange()` and passed to all data-fetching hooks as `dateRange` param. Example: if `viewMode='monthly'` and `currentMonth='2025-01'`, the range is Jan 1 to Jan 31, 2025; if `viewMode='fy'` and `currentFY='FY 2024-25'` with `fiscalYearStartMonth=4`, the range is Apr 1, 2024 to Mar 31, 2025. + +**Data Flow** — Totals, monthly aggregation, and quick insights are fetched server-side via `/api/calculations/*` endpoints and return pre-computed sums; transactions are fetched via `useTransactions()` and filtered client-side using `filterTransactionsByDateRange()` for Age of Money and Days of Buffering calculations. Recurring transactions for Fixed Commitments are fetched via `/api/analytics/v2/recurring-transactions` (separate hook). + +**Empty States** — Income Sources and Expense Sources cards each have a compact empty state (icon + title + message + action link); Quick Insights shows skeleton loaders during fetch; FinancialHealthScore shows "Need more transaction data..." if no transactions; no empty state for the entire Dashboard (header and time filter always visible). + +**Deep Links** — Income and Expense pie chart slices support click-through to category detail pages: `/income?category=` and `/spending?category=` respectively (category name is URL-encoded). + +**Preferences Coupling** — Dashboard respects `preferences.fiscal_year_start_month` (used to seed FY selector and constrain navigation), `preferences.savings_goal_percent` (passed to FinancialHealthScore for goal thresholds), `preferences.fixed_expense_categories` (used by FinancialHealthScore to classify essential expenses), and `displayPreferences.defaultTimeRange` (initial view mode). Widget visibility in Quick Insights is stored client-side in localStorage (`'ledger-sync-visible-widgets'` key) mapping widget title strings to legacy widget keys (e.g., "Savings Rate" → "savings_rate"); users can toggle in Settings → Dashboard Widgets (though new insights like Age of Money are always visible because they lack legacy keys). + +**MoM (Month-over-Month) Changes** — Computed server-side from monthly aggregation by comparing the two most recent complete months; only included in KPI subtitles if monthlyData exists; income/expense use `(current - prev) / prev * 100`; savings/net_savings use `(current - prev) / |prev| * 100` to handle sign flips; savings rate shows raw percentage-point delta (not ratio); label shows short month names (e.g., "Jan vs Dec"). + +**Financial Health Calculation** — FinancialHealthScore accepts `transactions` prop (from DashboardPage's filtered set); if not provided, falls back to `useTransactions()` (all transactions, unfiltered). Score is based on the last N months where N is computed from transaction date span. Investment account flag is checked via `useInvestmentAccountStore`, affecting whether investment flows are included in analysis. Radar chart abbreviates metric names (e.g., "Spend Less Than Income" → "Savings Rate"). + +--- + +## Spending Analysis +**Route:** `/spending` · **Reads from:** `/api/calculations/category-breakdown`, `/api/calculations/category-daily-series`, `/api/analytics/v2/cohort-spending`, client-side `useTransactions` hook + +One sentence purpose: Track, visualize, and analyze spending patterns across categories and time periods using the 50/30/20 budget rule framework. + +### What's on the page + +- **Total Spending** (metric card) — Sum of all expenses in the selected date range; *computed:* filter transactions by `type === 'Expense'` and selected date range, sum absolute amounts. + +- **Monthly Avg** (metric card) — Average spending per month across all months in the filtered range; *computed:* group expenses by `YYYY-MM`, divide total expense sum by distinct month count. Respects both date-range and category filters. + +- **Top Category** (metric card) — Category with the highest total spending; *computed:* from `categoryBreakdown`, sort by amount descending and take first entry. If no expenses exist, shows "N/A". + +- **Categories / Subcategories** (metric card) — Count of distinct categories and distinct `category::subcategory` pairs in filtered expenses; *computed:* count unique category names and unique (category, subcategory) tuples where `type === 'Expense' && subcategory` is defined. + +- **50/30/20 Budget Rule Analysis** (nested donut visualization) — Shows actual spending allocation vs. budget targets. Inner ring (muted, 40% opacity) displays target percentages (Needs/Wants/Savings from preferences); outer ring (full opacity) displays actual breakdown. + - Inner ring segments: **Needs (target %)**, **Wants (target %)**, **Savings (target %)** with target label inside rings + - Outer ring segments: actual categories mapped by spending type (**Needs**, **Wants**, **Savings**); *computed:* + - Needs amount: sum of transactions in `essential_categories` (from Settings → Essential Categories) + - Wants amount: sum of all other expense transactions + - Savings amount: `max(0, total_income - total_spending)` + - Percentages: `(category_amount / total_income) × 100` + - Target percentages from preferences: `needs_target_percent` (default 50), `wants_target_percent` (default 30), `savings_target_percent` (default 20) + - Ring legend below chart: "Inner = Target" and "Outer = Actual" + +- **Needs (target %)** (budget rule card) — Essential expenses (Housing, Healthcare, Food, etc.) + - Title: "Needs (N%)" where N is the needs_target_percent + - Subtitle: "Housing, Healthcare, Food, etc." + - Icon: ShieldCheck (blue) + - Value: absolute amount spent on essential categories + - Current: percentage of income spent on needs (red if overspending) + - Target: "≤N%" (from needs_target_percent) + - Status indicator: green checkmark if under target OR within +5pp tolerance, red warning if over by >5pp + - Bar color: blue (green if "on track", red if `isOverspendingEssential`) + - *Computed:* `(essential_amount / total_income) × 100`; overspend flag: `essentialPercent > needsTarget + 5` + +- **Wants (target %)** (budget rule card) — Discretionary expenses (Entertainment, Shopping, etc.) + - Title: "Wants (N%)" where N is the wants_target_percent + - Subtitle: "Entertainment, Shopping, etc." + - Icon: Sparkles (orange) + - Value: absolute amount spent on discretionary categories + - Current: percentage of income spent on wants + - Target: "≤N%" + - Bar color: orange (green if "on track", red if `isOverspendingDiscretionary`) + - *Computed:* `(discretionary_amount / total_income) × 100`; overspend flag: `discretionaryPercent > wantsTarget + 5` + +- **Savings (target %)** (budget rule card) — Remaining income after all expenses + - Title: "Savings (N%)" where N is the savings_target_percent + - Subtitle: "Income minus Expenses" + - Icon: PiggyBank (green) + - Value: `max(0, total_income - total_spending)` + - Current: percentage of income saved + - Target: "≥N%" (inverted inequality — must EXCEED target, not stay under) + - Bar color: green (red if `isUnderSaving`) + - *Computed:* `(savings / total_income) × 100`; undersave flag: `savingsPercent < savingsTarget - 5` + +- **Expense Trend** (area + line chart) — Monthly spending over time with 3-month rolling average overlay. + - X-axis: Formatted month labels (e.g., "Jan '25", "Feb '25") + - Y-axis: Currency amounts (formatted short, e.g., "$5K", "$10K") + - Area series: individual monthly expense total (red line with gradient fill) + - Line series (dashed): 3-month trailing rolling average (red dashed line) + - Reference line: horizontal dashed line at peak month expense with label (e.g., "Peak: $8.5K") + - *Computed:* + - Group expenses by `YYYY-MM`, sum per month + - For each month, rolling average = sum of (current month + prior 2 months) / 3 (trailing window, not centered) + - Peak expense = max monthly value + - Empty state: not rendered if no expense data + +- **Expense Breakdown** (expandable category table with stacked bar + sparklines) + - Header: Icon (BarChart3, purple), title "Expense Breakdown" or "**Category Name** Breakdown" if category-filtered, subtitle "N categories · **Total** total" + - Stacked overview bar: horizontal bar showing relative width of each category as percentage of total + - Per-category row (expandable): + - Color dot + - Category name (truncated if long) + - Percentage of total spend (right-aligned) + - Amount in currency (right-aligned, monospace font) + - Expand chevron (rotates 180° on expand) if subcategories exist + - Proportional bar: shows category's width as percentage with animation + - Sparkline (if ≥2 data points): 12-month trailing history regardless of selected date filter; *computed:* via `/api/calculations/category-monthly-history` server endpoint, client-side month-key calculation, mapped to category + - Sub-category rows (indented, appear on expand): + - Indent marker (small circle, lighter color) + - Subcategory name (truncated) + - Compact proportional bar (of category total, not grand total) + - Percentage of category (right-aligned) + - Amount (right-aligned) + - Read from: `/api/calculations/category-breakdown` (expense, date-scoped); `/api/calculations/category-monthly-history` (12-month trailing, independent of date filter) + - Empty state: if no categories, shows "No expense data available" with link to upload + +- **Pareto Analysis** (composed bar + line chart) — Which categories comprise 80% of spend (80/20 rule visualization). + - Left Y-axis: Spend amount in currency (bars) + - Right Y-axis: Cumulative percentage (line) + - X-axis: Category names (angled -30°), truncated if >14 chars + - Bars: individual category spending, orange for "vital few" (up to 80% threshold), muted gray for "trivial many" (beyond threshold) + - Blue line: cumulative percentage, dots at each bar + - Reference line: horizontal dashed gray line at 80% (or configurable threshold) with label + - Header: Icon (TrendingDown, orange), title "Pareto Analysis", subtitle "N categories make up 80% of your spend -- the rest are the long tail" + - Long tail handling: if >12 categories, roll tail into single "Other" bucket + - *Computed:* + - Sort categories by amount descending + - Running cumulative = sum of amounts up to current + - Cumulative % = (running_sum / total) × 100 + - Threshold index = first category where cumulative % ≥ 80 + - Color split: bars 0..threshold_index are orange ("vital few"), rest are muted ("trivial many") + - Read from: local client-side `categoryBreakdown` state (already computed from transactions) + - Not rendered if no data + +- **Top Merchants** (pie chart + scrollable list with toggle) + - Header: Icon (Store, orange), title "Top Merchants", subtitle "Where your money goes", toggle buttons: "By Amount" and "By Frequency" + - Pie chart (top 8 merchants): colored slices, no legend (uses list legend below) + - Merchant list (top 10, scrollable): + - Numbered badge (1-10, colored) + - Merchant name (truncated) + - "N visits · Avg **amount**" (subtitle) + - Total spent (right-aligned, bold) + - Summary row (below chart): + - "N Top Merchants" count + - "**Total** Total at Top 10" (sum of top 10) + - "M Total Visits" (transaction count across top 10) + - Filtering: + - Extracts merchant from transaction note (split by `-–—|,/`, remove long digit sequences, title-case) + - Filters by: `type === 'Expense'`, `note` exists, within date range, optionally filtered by category + - Minimum 2 transactions per merchant to include + - View modes: + - "By Amount": sort by `totalSpent` descending + - "By Frequency": sort by `transactionCount` descending + - *Computed per merchant:* total spent, transaction count, avg transaction, categories touched, first/last transaction date + - Empty state: "No merchant data available. Transaction notes help identify merchants." + +- **Multi-Category Time Analysis** (multi-line time series chart with controls) + - Header: Title "Multi-Category Time Analysis", transaction count "N expense transactions · bucketed **granularly**" (e.g., "daily", "weekly") + - Controls: + - "Granularity" dropdown: Auto, Daily, Weekly, Monthly (auto picks based on date-range span to keep legible) + - "Cumulative" / "Regular" toggle: switches between cumulative sum line and period amounts + - Export CSV button + - Chart: multi-line for top 6 categories by total spending; each line a different color + - *Computed:* + - Fetch daily per-category sums server-side: `/api/calculations/category-daily-series` (respects date-range + category filter in SQL) + - Client buckets into day/week/month based on auto-pick or user override + - For each bucket, sum amounts per category + - Cumulative mode: running total of category amounts over time + - Regular mode: per-period amounts + - Top 6 categories: sort by total across all periods, take top 6 + - Read from: `/api/calculations/category-daily-series` server endpoint + - Empty state: if no transactions, chart shows empty message + +- **Enhanced Subcategory Analysis** (multi-line time series chart with category dropdown) + - Header: Title "Enhanced Subcategory Analysis", export button + - Controls: + - Category dropdown (auto-populated from expense categories in date range), default "Food & Dining" or overridden by URL param `?category=` + - Granularity dropdown (Auto, Daily, Weekly, Monthly) + - Cumulative / Regular toggle + - Transaction count "N transactions · bucketed **granularly**" + - Chart: multi-line for all subcategories of selected category + - *Computed:* + - Fetch daily per-subcategory sums for selected category: `/api/calculations/category-daily-series?category=Food&Dining` + - Client buckets as above + - Subcategories from each row's `subcategory` field (or "Uncategorized") + - All subcategories rendered (no top-N limit like Multi-Category) + - Read from: `/api/calculations/category-daily-series` server endpoint with category filter + - Re-mounts when `categoryFilter` URL param changes (via `key={categoryFilter ?? 'all'}` prop), so no sync hook needed + - Empty state: "No data available for **selected category**" + +- **Spending Patterns** (bar chart with three toggle modes: By Day / By Date / Seasonal) + - Header: Icon (Calendar, teal), title "Spending Patterns", mode toggle buttons: "By Day", "By Date", "Seasonal" + - Subtitle (description changes per mode): + - "By Day": "Average spending by day of the week" + - "By Date": "Average spending by day of the month (highlights payday spending spikes)" + - "Seasonal": "Average monthly spending across years (highlights festival season Oct-Dec)" + - Bar chart: x-axis labels change per mode; bars show average spending + - "By Day": Sun–Sat (JS 0–6 order) + - "By Date": 1–31 (day of month) + - "Seasonal": Jan–Dec (month of year) + - Peak/Dip insight strip (below chart): + - Left box (teal): "Peak **Day/Date/Month**" with amount and delta from average (if >5% above) + - Right box (muted): "Quietest **Day/Date/Month**" name + - Bar sizing: "By Date" uses barSize 14 (narrow), others use 30 + - Bar color: teal with 0.7 fill opacity + - *Computed server-side (backend pre-computes):* + - Total spending per bucket (day-of-week 0–6, day-of-month 1–31, month-of-year 1–12) + - Count occurrences (e.g., how many Mondays in dataset) + - Average = total / occurrence-count + - Backend returns: `{ bucket, avg }` for each cohort + - Read from: `/api/analytics/v2/cohort-spending` server endpoint (no date range applied; uses all data) + - Empty state: if no expense data, shows "No expense data available" + +### Controls & notes + +- **Time Filter** (top-right of page header): `AnalyticsTimeFilter` component + - Options: Last 30 days, Last 3 months, Last 6 months, Last year, All time, Custom date range + - Applies to: Total Spending, Monthly Avg, Top Category, Expense Trend, Category Breakdown (including sparklines which ignore this and always show 12-month trailing), Multi-Category Time Analysis, Enhanced Subcategory Analysis + - Does NOT apply to: Spending Patterns (always uses full dataset) + - Stores in React Router `useAnalyticsTimeFilter` hook; syncs with URL params if present + +- **Category Filter** (banner below page header): + - Appears only when `?category=CategoryName` is in URL (deep-link from clicking a chart element) + - Click "X" to clear and remove URL param + - When active: + - Filters Top Merchants to only merchants in that category + - Filters Multi-Category Time Analysis is NOT affected (shows all top 6 categories) + - Auto-expands selected category row in Expense Breakdown + - Overrides Enhanced Subcategory Analysis dropdown initial value to the linked category + - Link source: clicking a pie slice, bar, or category row on other analytics pages + +- **Settings Coupling**: + - `essential_categories` (Settings → Essential Categories): determines which categories are "Needs" vs. "Wants" + - `needs_target_percent`, `wants_target_percent`, `savings_target_percent` (Settings → Budget Rule Targets): numeric targets for 50/30/20 budget rule display and overspend/undersave detection + - Any change to Settings clears React Query cache (`staleTime: Infinity` + invalidation on save) + +- **Empty States**: + - 50/30/20 Budget Rule section: if no spending breakdown possible (no income or no expenses matched), shows "No spending data available. Configure essential categories in Settings to see your spending analysis." with link to /settings + - Category Breakdown: if no expense transactions, shows "No expense data available. Upload your transaction data to see your expense breakdown." with link to /upload + - Top Merchants: if <2 transactions per merchant or no notes, shows "No merchant data available. Transaction notes help identify merchants." + - Multi-Category / Subcategory charts: if no data for selected range/category, shows empty message + - Spending Patterns: if no expense data, shows "No expense data available" + +- **Client-side vs. Server-side Computation**: + - **Server-side** (via `/api/calculations/*` and `/api/analytics/v2/*`): + - `/api/calculations/category-breakdown` — category-level totals (transaction_type, date-range scoped) + - `/api/calculations/category-monthly-history` — 12-month per-category history (pre-aggregated server, client computes month keys) + - `/api/calculations/category-daily-series` — daily per-category/subcategory sums (date range + category filter in SQL) + - `/api/analytics/v2/cohort-spending` — pre-computed day-of-week, day-of-month, month-of-year average spending (no date filter applied) + - **Client-side** (from `useTransactions` full ledger fetch): + - Total Spending, Monthly Avg, Top Category, Categories count, Savings + - Monthly Trend (group, rolling average, peak detection) + - 50/30/20 budget rule metrics (essentialPercent, discretionaryPercent, savingsPercent, overspend/undersave flags) + - Top Merchants (extract from notes, aggregate, filter, sort) + - **Rationale**: App recently migrated analytics from full-ledger client computation to server-side aggregation; legacy components (Expense Breakdown, Pareto, Top Merchants, Monthly Trend) still use client-side state, while newer sections (Spending Patterns, Subcategory Time Analysis) use server endpoints to avoid full fetch. + +- **Date Filter Behavior**: + - Monthly Trend (Expense Trend chart): shows only months within selected range, rolls up to 3-month average for that subset + - Category Breakdown sparklines: ALWAYS show 12-month trailing (last 12 calendar months from today), independent of selected date filter (to answer "is category spending up or down long-term?" separately from "how much this quarter?") + - Spending Patterns: ALWAYS uses all data, no date filtering (to answer "are Mondays always higher spend?" across full history) + +- **Animations**: + - Page animates in with scroll-fade-up (SCROLL_FADE_UP constant) + - Metric cards fade and slide + - Donut rings animate on load (target ring first at 0.3s delay, actual rings staggered) + - Budget Rule cards cascade in with 0.3–0.5s delays + - Charts animate with Recharts `isAnimationActive` based on `shouldAnimate(data.length)` (skips animation for very large datasets to avoid jank) + - Category rows and proportional bars animate with spring easings + - Expand/collapse uses `AnimatePresence` with height transition + +- **Accessibility**: + - Category breakdown bars are clickable (role="button", keyboard navigable with Enter/Space) + - Sparklines have `ariaLabel` describing 12-month trend + - Charts have `aria-label` and tooltips on hover + - Category dropdown, granularity selects, view toggles all have labels + +--- + +## Income Analysis +**Route:** `/income-analysis` · **Reads from:** `/api/calculations/income-analysis`, `/api/calculations/data-date-range`, `/api/calculations/category-breakdown`, `/api/calculations/category-monthly-history` + +Track and analyze income sources, trends, and cashback earnings across time periods with category-level detail. + +### What's on the page + +**Total Income** (metric card, green) — Sum of all income transactions within the selected date range. *Computed:* server-side sum of all `income` type transactions; includes all income categories unless category filter is applied. Key input: `total_income` from `GET /api/calculations/income-analysis`. + +**Primary Income Type** (metric card, blue) — The highest-earning income category by absolute amount within the date range. *Computed:* derived from `category_breakdown` response, sorted descending by value; returns the first (max) category name or "N/A" if no income. Key input: the category with `max(value)` from category breakdown. + +**Growth Rate** (metric card, adaptive color: green if positive, red if negative, blue if zero) — Percentage change in income trend over the selected period. *Computed:* server-side calculation comparing income trajectory; rendered green, red, or blue depending on sign. Key input: `growth_rate` from income analysis response. + +**Cashbacks Earned** (metric card, teal) — Total non-taxable income (cashbacks and refunds) recognized as such via user preferences. *Computed:* server-side sum of transactions matching categories listed in `preferences.non_taxable_income_categories`; sum is passed as `cashback_categories` array parameter to the API. Key input: `cashbacks_total` from income analysis. + +**Income by Category** (pie chart + breakdown cards) — Proportional breakdown of all income by category with color-coded cards showing icon, category name, percentage of total, and absolute amount. *Computed:* `category_breakdown` from API response, filtered to only categories with value > 0, sorted descending by amount; each entry is rendered as a donut slice (inner radius 50px, outer radius 90px) and a corresponding detail card. Clicking a slice navigates to `/transactions?type=Income&category={encoded_name}`. Category icons are hardcoded per name (Employment Income → Briefcase, Investment Income → TrendingUp, Refund & Cashbacks → Wallet, One-time Income → PiggyBank, Business/Self Employment Income → Activity, Other Income → DollarSign). Category colors come from `INCOME_CATEGORY_COLORS` map in `lib/preferencesUtils.ts` (Employment Income: green, Investment Income: orange, Refund & Cashbacks: teal, One-time Income: purple, Other Income: tertiary gray, Business/Self Employment Income: pink). + +**Empty state (Income by Category)** — If no categories with income > 0, displays "No income type data available" with a link to Settings where users can configure income categories. + +**Income Trend** (area chart with line overlay) — Monthly income with a dashed-line 3-month rolling average overlay, plus a horizontal reference line marking the peak income within the range. *Computed:* `monthly_data` array from API response; each month has `income` (solid green area), `income_avg_3m` (dashed green line), and a `month` key formatted via `formatMonthKey(month, { month: 'short', year: '2-digit' })` for x-axis labels. Tooltip displays full month name (long format) and labels the average as "Income (3m avg)" vs. "Income". A reference line at `peak_income` is labeled "Peak: ${formatCurrencyShort(peak_income)}" and is always drawn (regardless of date filter). Chart animates on load if length > 1; single data point shows a dot instead. Key inputs: `monthly_data[]`, `peak_income` from API. + +**Empty state (Income Trend)** — If no monthly data, displays "No income data available" prompting user to upload transaction data. + +**Income Sources** (expandable category breakdown table; from `CategoryBreakdown` component) — Bar-style breakdown of income sources by category with: + - **Header** — Icon (DollarSign, green) and title "Income Sources" with category count and total amount. + - **Stacked overview bar** — Visual representation showing each category's proportion of total income as a colored segment; clicking any segment expands that category's row. + - **Category rows** — Each row shows: + - Color dot + - Category name (clickable to expand/collapse subcategories if present) + - Percentage of total income (e.g., "42.5%") + - Absolute amount formatted as currency + - Expand chevron (if subcategories exist) + - **Proportional bar** — Horizontal bar showing the category's share of total (respects the active date filter) + - **12-month sparkline** — Tiny 12-month trend chart showing that category's spending pattern over the **trailing 12 months from today** (independent of the selected date range); appears only if 2+ months of history exist + - **Subcategory rows** (expanded view) — Indented rows under each category, showing subcategory name, bar, percentage of category, and amount + - *Computed:* Server-side aggregation via `GET /api/calculations/category-breakdown?transaction_type=income&start_date=...&end_date=...`; monthly history for sparklines via `GET /api/calculations/category-monthly-history?months=...&transaction_type=income`; categories colored via `INCOME_CATEGORY_COLORS` map (with deterministic hash-based fallback for unknown categories); sparkline is always trailing 12 months regardless of date range selected. + +**Empty state (Income Sources)** — If no income categories, displays "No income data available" with link to upload. + +### Controls & notes + +**Time filter** — Dropdown at top-right of page header (component `AnalyticsTimeFilter`) with options: "Year" (calendar year), "Month" (current calendar month), "Fiscal Year" (user-configured start month, default April). Date range bounds are fetched lightweight via `/api/calculations/data-date-range` (no full-ledger fetch); bounds are clamped to user's "Earning Start Date" preference if enabled. Changing the time filter updates the `dateRange` and refetches all metrics and breakdowns server-side. + +**Category filter (deep-link)** — Query parameter `?category={encoded_name}` (e.g., `/income-analysis?category=Employment%20Income`) filters all stats to a single category; the filter label shows in the `FilterBanner` component at top of page. Clicking a pie slice in "Income by Category" navigates to the category filter. Clicking the banner's clear button removes the filter and restores the full breakdown. The category filter is passed to the API as the `category` parameter and affects all card metrics and charts. + +**Preference coupling** — The `non_taxable_income_categories` list from user preferences (set in Settings) is passed to the API as `cashback_categories` and controls which transactions are summed into the "Cashbacks Earned" metric. The `fiscal_year_start_month` preference controls the "Fiscal Year" time filter option. The `useEarningStartDate` and `earningStartDate` preferences clamp the start of all time ranges so charts visually begin at the user's earning date (underlying data untouched). + +**Loading state** — While data loads, a `PageSkeleton` component displays; individual sections show "Loading chart..." or similar spinners. + +**API query keys** — React Query caches are keyed on `['income-analysis', dateRange.start_date, dateRange.end_date, categoryFilter, cashbackCategories]` for the main metrics; `['category-breakdown', ...]` for the Income Sources section; `['category-monthly-history', transactionType, monthKeys]` for the sparklines. Stale time is `Infinity` (data does not auto-refetch until manually invalidated). + +--- + +## Income/Expense Flow (Sankey) +**Route:** `/income-expense-flow` · **Reads from:** `useTransactions()` hook (fetches full transaction ledger; server applies account-level exclusions) + +Visualize how income flows from source categories into savings and expense categories through an interactive Sankey diagram. + +### What's on the page + +**Total Income** (metric card) — Sum of all Income transactions in the selected period. Displayed in green with a TrendingUp icon. *Computed:* Sum of `transaction.amount` for all transactions where `type === 'Income'` within the selected date range, excluding transfers. + +**Total Expense** (metric card) — Sum of all Expense transactions in the selected period. Displayed in red with a TrendingDown icon. *Computed:* Sum of `transaction.amount` for all transactions where `type === 'Expense'` within the selected date range, excluding transfers. + +**Net Savings** (metric card) — Total Income minus Total Expense. Displayed in primary color if non-negative, red if negative. Icon color reflects the sign. *Computed:* `totalIncome - totalExpense`. Shows absolute value formatted as currency. + +**Savings Rate** (metric card) — Percentage of income saved. Displayed in green if >= 20%, yellow otherwise. *Computed:* `(netSavings / totalIncome) × 100` if totalIncome > 0, else 0. + +**Cash Flow Sankey** (chart, desktop only) — Interactive flow diagram showing income sources (left) → Total Income (center) → Savings & Expenses (center) → Expense categories (right). Chart height is 700px; width adapts to container. Rendered via Recharts `` with `nodeWidth={20}` and `nodePadding={60}`. + - Top 10 income categories by amount flow from left into "Total Income" node. + - "Total Income" node splits into "Savings" and "Expenses" nodes. + - "Expenses" node splits into top 10 expense categories. + - Only the top 10 categories (by amount, descending) are shown per section; categories beyond the top 10 are aggregated into the totals but not visually rendered. + - *Node colors:* Income categories are green/teal, "Total Income" is indigo-vibrant, "Savings" is purple, "Expenses" is pink, expense categories are red/orange/yellow. + - *Link colors:* Purple with 25% opacity. + - *Node labels:* Each node shows category name, currency amount, and percentage of total income. + - *Tooltips:* On hover, shows currency amount. + +**Cash Flow (mobile view) — Vertical stacked flow** (mobile only, when not loading and links exist). Alternative to Sankey for small screens: + - **Income sources** section: Lists top 10 income categories as animated horizontal bars. Bar width proportional to category's share of highest income category. + - **Total Income** pill: Displays sum of all income. + - **Savings / Expenses** split cards: Two-column layout showing savings amount/percent and expenses amount/percent. + - **Where expenses went** section: Lists top 10 expense categories as animated horizontal bars, width proportional to category's share of highest expense category. + - Each row displays: category name, percentage of section total, animated bar, and amount. + +**Legend** (desktop Sankey only) — Three color-coded rows below the chart: + - Green gradient: "Income Sources" + - Indigo-to-purple gradient: "Total Income / Savings / Expenses" + - Red-to-orange gradient: "Expense Categories" + +### Controls & notes + +**Time Filter** (top right): + - View Mode selector: "All Time", "FY" (Financial Year), "Yearly", "Monthly" + - Navigation arrows (disabled at boundaries): Previous/Next period buttons + - Default mode: User's preference from Display Settings; falls back to "FY" + - Period label: Updates based on selection (e.g., "FY 2024-25", "2024", "January 2024", "All Time") + - Fiscal year start month: Defaults to April; respects user preference from `/api/preferences` + - **Earning-start-date preference**: If enabled in Display Settings, the chart's date range start is clamped to the earning start date (view-layer only; underlying transaction data is untouched) + +**Empty state**: If no links exist in the Sankey (i.e., no transaction data for the selected FY), the chart displays a message: "No transaction data available for FY [currentFY]" with a subtitle prompting to select a different FY or upload data. + +**Loading state**: Spinner with "Loading flow diagram..." text while `useTransactions()` is fetching. + +**Data derivation**: + - All computation is **client-side** within the hook `useIncomeExpenseFlow()` and derived from the full transactions ledger. + - Transactions are fetched once via `useTransactions()` with `staleTime: Infinity` (cached indefinitely; only invalidated on upload). + - Transactions are filtered to exclude `is_transfer` transactions. + - Transactions are then windowed by the selected date range (`dateRange.start_date` and `dateRange.end_date`). + - Categories default to "Other Income" / "Other Expense" if `transaction.category` is null. + - **No server analytics endpoints are called** — the page relies entirely on client-side aggregation of the full ledger. + +**Responsive behavior**: + - Desktop (≥768px): Recharts Sankey diagram (700px height) with legend. + - Mobile (<768px): Vertical flow view with stacked sections and animated bars; Sankey is hidden. + - Summary cards: 4-column grid on desktop (lg), 2-column on tablets (sm), 1-column on mobile. + +**Device detection**: `useIsMobile()` hook determines layout. Font size in Sankey node labels adjusts: 13px desktop, 11px mobile. + +--- + +## Budgets +**Route:** `/budgets` · **Reads from:** `/api/calculations/category-breakdown` (via `useCategoryBreakdown`), `/api/transactions`, `/api/preferences` + +Set spending limits by category or subcategory, track actual vs. budgeted amounts with monthly or yearly periods, and monitor budget health across your expense categories. + +### What's on the page + +**Category / Subcategory toggle** (view mode selector) — Switch between category-level budgets and subcategory-level budgets. Changes which budgets display and which categories are available in the form. Default: Category. + +**Add Budget button** (action button) — Opens the "New Budget" form to create a new budget. + +**Total Budget** (stat card) — Formatted currency; *computed:* sum of all `limit` values for budgets in the current view (filtered by viewMode). Displays only if at least one budget exists. + +**Total Spent** (stat card) — Formatted currency with color coding (green if under budget, red if over); *computed:* sum of all `spent` values across filtered budget rows. Reads from transaction ledger filtered by current month (`YYYY-MM`) or fiscal year (for yearly budgets). Displays only if at least one budget exists. + +**On Track** (stat card) — Count of budgets with `status === 'safe'`. Displays only if at least one budget exists. + +**Exceeded** (stat card) — Count of budgets with `status === 'exceeded'`. Displays only if at least one budget exists. + +**New Budget form** (collapsible form section) — Opens when "Add Budget" button is clicked. Contains: +- **Category dropdown** (category mode): Lists all categories from `availableCategories` (categories without existing budgets). If in subcategory mode, shows only categories that have subcategories and resets subcategory selection when category changes. +- **Subcategory dropdown** (subcategory mode): Lists subcategories for the selected category. Disabled if no category is selected. +- **Limit (₹) input**: Numeric field; must be > 0 to submit. +- **Period dropdown**: Defaults to "Monthly"; options are "Monthly" or "Yearly". +- **Add button**: Disabled if category is empty or limit is empty. On submit, calls `setBudget(key, limit, period)` where key is `category` or `category::subcategory` (depending on mode), then closes the form. Toast error if limit ≤ 0 or non-finite. + +**Budget Tracker rows** (list of budget items, one per budget) — Each row displays a single budget with the following elements: +- **Status icon & category name** (header row): Green check circle if "safe", yellow/orange warning icon if "warning"/"danger", red alert triangle if "exceeded". Shows category name, and if subcategory exists, appends " / subcategory". Includes a badge showing period ("monthly" or "yearly"). +- **Fixed expense badge** (optional): Displays if the category (lowercased) is in `fixedExpenseCategories` (from preferences `fixed_expense_categories`). +- **Momentum sparkline + slope** (optional): Appears if `categoryMomentum[category]` exists and has ≥3 sparkline data points. Shows a small area chart of spending trend + slope percentage (color: red=accelerating/increasing, green=decelerating/decreasing, yellow=stable). +- **Percentage display** (right side): Large, bold percentage of spent/limit; colored by status (green=safe, yellow=warning, orange=danger, red=exceeded). +- **Edit button**: Inline icon; click to open number input for new limit. +- **Delete button**: Trash icon; requires confirmation; removes budget from store. +- **Limit input** (edit mode): Appears when edit button is clicked; number input with focus-on-load. On blur or Enter, saves new limit; on Escape, cancels. +- **Progress bar** (visual): Animated bar showing `min(100, percentage)%` fill. Background has three zones: 0–75% of alert threshold (5% opacity), 75–100% of alert threshold (10% opacity), and 100%+ of alert threshold (10% opacity). A yellow vertical line marks the alert threshold position. +- **Remaining/Over budget text** (status line): Green "X remaining to spend" if positive; red "X over budget" if negative. Uses `limit - spent`. +- **Spent and limit footer**: "X spent · of Y" in small, muted text. + +**Budget vs Actual chart** (bar chart) — Shows top 8 budgets (sorted by percentage descending) with two bars per category: Budget (semi-transparent blue) and Spent (colored by status). X-axis rotates labels at -20° if many categories. Clicking a Spent bar deep-links to `/transactions?category=`. Tooltip displays formatted currency. + +**Budget Burn-down chart** (area chart, monthly only) — Appears if any monthly budgets exist and `burndownData.length > 0`. Shows remaining budget remaining over the month (day 1–last day). Two lines: dashed gray "Ideal Pace" (budget / daysInMonth for each day) and solid green "Actual Remaining" (cumulative spent subtracted from total budget, undefined after today). Only computed for budgets with `period === 'monthly'`. Displays current month name and year. Tooltip shows "Day N" label. + +**Category Usage Radar** (radar chart) — Appears if ≥3 subcategory budgets exist (after `radarData.slice(0, 8)`). Shows budget utilization (%) on a 0–100 scale (or max utilization if > 100). Each spoke is a category name (truncated to 10 chars + "…" if longer). Title: "Budget utilization (%) across all categories". + +**Suggested Budgets** (pill buttons section, optional) — Appears if: +- At least one budget exists AND there are categories without budgets AND some available categories have > ₹500 spending in the current month. +- Shows up to 8 suggestions, filtered to those with monthly spending > ₹500. +- Each pill: "+ Category Name (₹X/mo)". On click, calls `handleQuickAdd(category, spent)`, which suggests `ceil(spent * 1.2 / 1000) * 1000` (20% buffer, rounded to nearest ₹1000). Period is always "monthly". + +**Empty state** (when no budgets exist) — Displays a piggy-bank icon, heading "No budgets set yet", description "Set spending limits for your categories to start tracking…", and a "Create Your First Budget" button that opens the form. + +### Controls & notes + +**Time filtering:** Budgets are always tracked against the current calendar month for monthly periods, and the current fiscal year (based on `fiscal_year_start_month` from preferences, default April) for yearly periods. No user-facing time filter on the page; period is set per-budget at creation and displayed on each row. + +**View mode toggle (Category / Subcategory):** Instantly filters displayed budgets and updates available categories in the form. Does not persist across sessions (resets to "Category" on reload). + +**Period selection:** Can be set at budget creation time in the form. Existing budgets can be edited inline to change period. Monthly budgets compare against current month spending; yearly budgets compare against fiscal-year spending. + +**Alert threshold:** Read from preferences `default_budget_alert_threshold` (default 80%). Determines status transitions: +- `0–74.99%`: "safe" (green) +- `75–`, filtering the transaction list by that category. + +**Data sources:** +- **Current month spending:** Computed client-side from transactions ledger, filtered by `tx.date.startsWith(currentMonthKey)` where currentMonthKey = "YYYY-MM" (local date). +- **Fiscal year spending:** Computed from transactions filtered by fiscal-year start/end dates (derived from `getCurrentFY()` and `fiscal_year_start_month` preference). +- **Spending data:** All transactions with `type === 'Expense'` are summed by category (or `category::subcategory` for subcategories). Amounts are absolute value. +- **Category list (for form):** Combines categories from `/api/calculations/category-breakdown` (all-time, via `useCategoryBreakdown`) and categories from the live transaction ledger. +- **Preferences:** Reads `fiscal_year_start_month`, `default_budget_alert_threshold`, and `fixed_expense_categories` from `/api/preferences`. +- **Momentum:** Computed per category from recent transaction trends using `computeCategoryMomentum(transactions)`, which generates sparkline data and slope classification. + +**Budget storage:** Budgets are stored in Zustand (`useBudgetStore`) with localStorage persistence (key: "ledger-sync-budgets"). Each budget object has `{ category: string, limit: number, period: 'monthly' | 'yearly' }`. + +**Empty chart states:** Budget vs Actual, Burn-down, and Radar charts each show a placeholder if no data is available (e.g., no budgets, or Burn-down if no monthly budgets exist). + +--- + +## Trends & Forecasts +**Route:** `/forecasts` · **Reads from:** `/api/analytics/trends via useTrends(timeRange) - returns monthly_trends array with fields: month (YYYY-MM), income, expenses, surplus`, `/api/calculations/monthly-aggregation via useMonthlyAggregation() - server-side monthly aggregation with income, expense, net_savings`, `useTransactions hook - full transaction ledger (date, type, amount) for computing daily cumulative savings rate`, `usePreferences hook - reads savings_goal_percent (default 20), fiscal_year_start_month, useEarningStartDate flag` + +Analyze multi-month income, expense, and savings patterns with 12-month projections and confidence bands. + +### What's on the page + +- **Spending Trend** (metric card) — Current month's total expenses (currency format), percentage change from previous month with trend icon (up=bad, stable, or down=good), plus two stats: average expense across all filtered months and peak (highest) month's expenses. *Computed:* Current = latest month expenses from /api/analytics/trends. Previous = prior month. ChangePercent = (latest - previous) / previous * 100. Direction: up if > 2%, down if < -2%, else stable. Average = sum(expenses) / count. Highest = max(expenses). Data filtered by selected time range (all_time, fiscal year, or yearly). +- **Income Trend** (metric card) — Current month's total income (currency format), percentage change from previous month with trend icon (up=good, stable, or down=bad), plus average and peak income across filtered months. *Computed:* Current = latest month income from /api/analytics/trends. Previous = prior month. ChangePercent = (latest - previous) / previous * 100. Direction: up if > 2%, down if < -2%, else stable. Average = sum(income) / count. Highest = max(income). +- **Savings Trend** (metric card) — Current month's savings (income minus expenses; displayed in green if positive, red if deficit), percentage change from previous month, average savings across filtered months, and best month (highest savings value, shown in green). *Computed:* Current = latest.income - latest.expenses (API field: surplus). Previous = prior month surplus. ChangePercent = (latest - previous) / previous * 100. Direction: up if > 2%, down if < -2%, else stable. Average = sum(surplus) / count. Highest = max(surplus). For all-deficit users, peak is the least-negative month (not clamped to zero). +- **Income & Expense Trends** (line chart) — Three synchronized sub-charts (Income, Expenses, Savings), each displaying monthly actual value as filled area chart (green for income, red for expenses, purple for savings) with a dashed 3-month rolling average overlay line. Horizontal reference line marks the peak value for each category. Hovering a month highlights a vertical sync line across all three charts. *Computed:* Data from filtered monthly_trends via useTrends (filtered by time range). For each month: rolling_avg = sum(current month + 2 prior months) / 3 window. Peak values: peakIncome = max(all income months), peakExpenses = max(all expense months), peakSavings = max(all surplus months, unfloor for all-deficit users). Charts animate with 600ms easing; animation disabled if <= 1 data point. +- **Savings Rate Trend** (area chart) — Cumulative daily savings rate (%) starting from 0% and building as user's cumulative net (income - expenses) grows relative to cumulative income. Green horizontal reference line shows the user's savings goal target percentage (default 20% from preferences). Negative savings rates display as 'percent (deficit)' in tooltips; the area chart clamps negative to 0% for visualization but preserves true negative in hover data. *Computed:* Built from filteredTransactions (filtered by time range via dateRange). For each calendar day: cumIncome = sum of all income transactions up to that day, cumExpense = sum of all expense transactions to that day. savingsRate = (cumIncome - cumExpense) / cumIncome * 100. Display value = max(0, savingsRate) to keep area above zero. rawSavingsRate (true value, possibly negative) stored in data payload for tooltip. savingsGoalPercent from usePreferences (field: savings_goal_percent, default 20). +- **Month-on-Month Breakdown** (table) — Last 8 months of summary data in a sortable table with five columns: Month (YYYY-MM), Income (green text), Spending (red text), Savings (purple text if positive, red if negative/deficit), Savings Rate (%). All numeric columns are right-aligned and sortable by clicking headers. *Computed:* Rows = chartData.slice(-8) from useTrendsForecasts hook, representing the last 8 filtered months. Income and Spending sourced from /api/analytics/trends monthly_trends. Savings = income - expenses (surplus field). Savings Rate = (surplus / income) * 100 if income > 0, else 0. Data filtered by selected time range. +- **Future Cash Flow Forecast** (area chart) — Complex multi-layer visualization combining historical and forecast periods: (1) historical income line (solid green, 50% opacity), (2) historical expenses line (solid red, 50% opacity), (3) historical net savings (solid blue area with gradient fill), (4) forecast income line (dashed green, 35% opacity), (5) forecast expenses line (dashed red, 35% opacity), (6) forecast net savings (dashed purple), and (7) widening confidence band (blue gradient cone) representing optimistic-to-conservative range around forecast. Header displays 'Deficit in Xmo' warning badge if forecast turns negative. *Computed:* Uses useMonthlyAggregation() to fetch /api/calculations/monthly-aggregation (server-side aggregated monthly data). buildForecast() function requires >= 3 complete months; excludes incomplete current month if today's date < 25th. Trend calculation: last 6 complete months' income and expense growth rates. Forecast generation: 12-month projection where each month's projection *= 1 + (growth * 0.5 damping). Confidence band: upper/lower = net +/- (stdDev * 0.8 * sqrt(months_from_start)). Months until negative = index of first forecast month with net < 0 (null if never turns negative). +- **Avg Monthly Income (Forecast Insight Card)** (metric card) — Average monthly income from the last 6 complete months (shortened currency format, e.g., $5.2K), with a growth trend indicator showing arrow (↑ or ↓) and percentage monthly growth. *Computed:* avgIncome = sum(last 6 complete months income) / 6. incomeGrowth = ((last_month_income - first_month_income) / first_month_income) / 5_periods * 100 (converted to percentage display). Trend arrow: ↑ if growth >= 0, ↓ if < 0. +- **Avg Monthly Expenses (Forecast Insight Card)** (metric card) — Average monthly expenses from the last 6 complete months (shortened currency format), with a growth trend indicator showing arrow (↑ or ↓) and percentage monthly growth. *Computed:* avgExpense = sum(last 6 complete months expenses) / 6. expenseGrowth = ((last_month_expense - first_month_expense) / first_month_expense) / 5_periods * 100. Trend arrow: ↑ if growth >= 0, ↓ if < 0. +- **1-Year Projected Savings (Forecast Insight Card)** (metric card) — Sum of all net savings (income minus expenses) across the entire 12-month forecast window (shortened currency format). Colored blue if positive/surplus, red if total projected deficit. Label states 'Based on current trends'. *Computed:* projectedSavings = sum of (forecast_income - forecast_expense) for all 12 forecast months. Each month applies the damped growth trend: current_forecast *= (1 + growth * 0.5). Returns positive (blue) or negative (red) based on value. + +### Controls & notes + +Page-level controls: AnalyticsTimeFilter component (top-right header) with dropdown selector for view modes (all_time, fy [fiscal year], yearly) and secondary year/month/FY picker. Time range selection filters all metrics, charts, and forecast base data (last 6 months used for trend). Empty state handling: displays 'Upload your transaction data to see spending trends and forecasts' with link to /upload if no monthly_trends exist. Forecast empty state: 'Need at least 3 months of data for forecasting' if fewer than 3 complete months. Current month exclusion logic: if today's date < 25th, the current month is excluded from trend calculations to avoid skewed rolling averages. Savings Rate trend clamps negative values to 0% for chart area rendering but preserves true negative values (rawSavingsRate) in tooltip payload for transparency. Trend direction determination: change is marked 'stable' if percent change is within +/- 2% threshold; 'up' or 'down' otherwise. Confidence band behavior: widens with forecast horizon using sqrt(months_out) to reflect increasing uncertainty over time. No query parameter deep-links (e.g., ?category=) are used on this page. Preference coupling: fiscal_year_start_month affects FY-mode range; earningStartDate clamps view range start if useEarningStartDate is enabled; savingsGoalPercent sets the reference line on Savings Rate chart." + +--- + +## Period Comparison +**Route:** `/comparison` · **Reads from:** `useTransactions() - fetches all user transactions from /api/transactions (account filters applied server-side)`, `usePreferences() - fetches user preferences from /api/preferences (specifically fiscal_year_start_month for FY calculations)` + +Compare financial metrics (income, expenses, savings, rates, and categories) across two time periods by selecting month, calendar year, or fiscal year. + +### What's on the page + +- **Period Selector** (toggle + form) — A tab-like toggle at the top of the page with three comparison modes (Month, Year, FY) and below it two sets of dropdown selectors labeled 'Period A' and 'Period B' with a 'vs' separator. Users click the mode tab to switch the selector dropdowns, then choose specific periods to compare. Defaults to fiscal year comparison with Period A as the prior year and Period B as the current fiscal year. *Computed:* Mode toggles between 'month', 'year', or 'fy' (CompareMode state). Available options are derived from transactions: months are extracted from transaction dates (YYYY-MM format sorted descending), years are unique years from transaction dates, and fiscal years are computed from transactions using the user's fiscal_year_start_month preference. Month defaults fall back to the two most recent complete months (before current month). Year defaults are prior year and current year. FY defaults are prior FY and current FY. +- **Income (KPI Card)** (metric card) — Large colored text showing Period B's total income with the label and amount. Below shows Period A's label and amount in smaller text. A colored badge shows the percentage change with an up/down/neutral arrow icon and the sign-prefixed change percentage. *Computed:* Summed from all transactions where type='Income' within the selected period. Percentage change = (periodB - periodA) / periodA * 100, or 100 if periodA is zero. Displays in formatted currency. Arrow color: green for positive (income growth is good), red for negative. Card color from SEMANTIC_COLORS.income. +- **Expenses (KPI Card)** (metric card) — Large colored text showing Period B's total expenses with the label and amount. Below shows Period A's label and amount. A change badge shows percentage change with arrow icon (red for increase, green for decrease since invertChange=true). *Computed:* Summed from all transactions where type='Expense' within the selected period. Percentage change = (periodB - periodA) / periodA * 100. For expenses, arrow color is inverted: green for decrease (good cost control), red for increase. Card color from SEMANTIC_COLORS.expense. +- **Savings (KPI Card)** (metric card) — Large colored text showing Period B's total savings (income minus expenses) with label and amount. Below shows Period A's savings. A change badge shows percentage change with arrow icon (green for increase, red for decrease). *Computed:* Savings = income - expense for each period. Percentage change = (periodB.savings - periodA.savings) / periodA.savings * 100. Positive change is good. Card color from SEMANTIC_COLORS.savings. +- **Savings Rate (KPI Card)** (metric card) — Large text showing Period B's savings rate as a percentage (e.g., '42.5%') with label and amount. Below shows Period A's rate. A change badge shows the percentage point change with arrow icon (green for increase, red for decrease). *Computed:* Savings rate = (savings / income) * 100 if income > 0, else 0. Change is displayed in percentage points (delta, not percentage): periodB.savingsRate - periodA.savingsRate. Card color is app.purple. +- **Financial Overview - Income** (bar chart) — Horizontal metric row titled 'Income' with a bar chart showing two overlaid bars (Period A faded at 35% opacity, Period B solid) extending from the same left edge. Values are labeled on the left (Period A) and right (Period B) with formatted currency. A change badge with percentage and arrow displays in the top right. The bar is color-coded (SEMANTIC_COLORS.income). Height is normalized to the largest value across all four metrics. *Computed:* Bar width for each period = (value / maxValue) * 100, where maxValue = max(all period income/expense values for this session, minimum 1). Both bars anchored at left edge so delta reads as visual gap. Percentage change = (periodB - periodA) / periodA * 100. +- **Financial Overview - Expenses** (bar chart) — Horizontal metric row titled 'Expenses' with overlaid bars (same layout as Income). Change badge color is inverted: green for expense decrease, red for increase. *Computed:* Same bar layout as Income. invertChange=true so positive change shows red (expense growth is bad), negative change shows green. Percentage change = (periodB - periodA) / periodA * 100. +- **Financial Overview - Savings** (bar chart) — Horizontal metric row titled 'Savings' with overlaid bars (same layout). Change badge is green for increase, red for decrease. *Computed:* Same bar layout. Savings = income - expense for each period. Percentage change = (periodB.savings - periodA.savings) / periodA.savings * 100. +- **Financial Overview - Savings Rate** (bar chart) — Horizontal metric row titled 'Savings Rate' with overlaid bars showing percentages. Bar width is normalized to maxValue=100 (percentage scale). Values display with '%' suffix. Change badge shows percentage points, not percentage. *Computed:* Savings rate = (savings / income) * 100 if income > 0, else 0. Bar width = (rate / 100) * 100. Percentage point change = periodB.savingsRate - periodA.savingsRate (e.g., '+5.2 pts'). +- **Spending Distribution** (bar chart) — A butterfly chart with categories on the Y-axis (max 15 categories by total spend) and spend amount on the X-axis. Period A bars extend left (blue, 35% opacity if Period B won that category, 95% opacity if Period A won). Period B bars extend right (indigo, 95% opacity if Period B won, 45% opacity if Period A won). Categories are sorted by total spend (A+B) descending. Legend at top shows Period A (left, blue) and Period B (right, indigo). Labels show formatted short currency on bar ends. *Computed:* For each period, extract top 8 expense categories by value (value > 0), merge both lists into union of categories, sort by max(periodA, periodB) descending, truncate to top 15. For each category: periodA displayed as negative (forces left extension in stacked bar), periodB as positive (right). Color opacity: entry.aWins (periodA >= periodB) ? 95% for A, 45% for B; else 45% for A, 95% for B. Chart domain is [-maxVal, maxVal] symmetric. Hover shows exact formatted currency. +- **Expense Categories** (table) — Left-side card (on lg+ screens) with icon (red down arrow), title 'Expense Categories', and row count badge. Each row shows category name, percentage change in a colored badge (green for decrease, red for increase), and overlaid bars showing Period A (faded) and Period B (solid). Left and right values display formatted currency. Max height is scrollable (300px on mobile, 400px tablet, 520px desktop). Empty state message if no expenses. *Computed:* Collects all expense CategoryDelta records (periodA and periodB values, change = pctChange(periodB, periodA), changeAbs = periodB - periodA). Sorted by max(periodA, periodB) descending. For each row: bar width = (value / maxValue) * 100 where maxValue = first row's max(periodA, periodB). invertChange=true so positive change = red (bad), negative = green (good). Each row animates in with staggered delay (index * 0.03s). Rows are colored indigo (colorB). Change badge shows formatted percentage. Values show formatted currency. +- **Income Categories** (table) — Right-side card (on lg+ screens) with icon (green up arrow), title 'Income Categories', and row count badge. Layout identical to Expense Categories but change colors are not inverted (green for increase = good, red for decrease = bad). Shows income categories with overlaid bars and percentage changes. *Computed:* Same as Expense Categories but filtered to income CategoryDelta records (type='income') and invertChange=false. Positive change = green (income growth is good), negative = red (bad). +- **Quick Stats - Transactions** (metric card) — Small card showing label 'Transactions' with two columns: left shows Period A label and transaction count, right shows Period B label and count. Numbers are right-aligned and bold. *Computed:* Count of all transactions within each period (transaction.count field from PeriodSummary). Formatted as rounded integer. +- **Quick Stats - Avg Daily Spend** (metric card) — Small card showing label 'Avg Daily Spend' with two columns showing Period A and B values in formatted short currency. Divides period expense by period's day count. *Computed:* Average daily spend = period.expense / period.days. Days computed as inclusive calendar day span: Math.round((endDate - startDate) / 86400000) + 1, where dates are in local midnight. Formatted as short currency. +- **Quick Stats - Categories Used** (metric card) — Small card showing label 'Categories Used' with Period A and B column counts (number of distinct categories with any transaction in that period). *Computed:* Count of Object.keys(period.categories).length (categories with at least one transaction, income or expense). Formatted as rounded integer. +- **Quick Stats - Top Expense** (metric card) — Small card showing label 'Top Expense' with Period A and B values showing the single highest expense amount in that period across all categories. *Computed:* Math.max(...Object.values(period.categories).map(c => c.expense), 0). Formatted as currency. +- **Key Insights** (list) — Section titled 'Key Insights' with a lightbulb icon, containing a vertical list of auto-generated insight strings. Each insight is displayed in a rounded box with a small orange dot bullet. Insights appear only if the list is non-empty. Each insight animates in with a staggered delay. *Computed:* Generated by generateAllInsights(periodA, periodB, expenseDeltas). Returns array of strings from seven generators: (1) Income growth/drop if >= 5% change, (2) Expense increase/decrease if >= 5% change, (3) Savings rate shift if >= 3 percentage points, (4) Top category swing if largest absolute change > 0, (5) New categories appearing in B but not A (displayed up to 3), (6) Categories gone from A to B (up to 3), (7) Transaction volume if >= 15% change. Each generator returns null if threshold not met; only non-null strings are included. + +### Controls & notes + +CONTROLS: Three-tab toggle (Month/Year/FY) at page top switches comparison mode; dropdowns for each period change based on mode. No URL query parameters for deep linking (comparison state is ephemeral). EMPTY STATE: Shows 'No transactions yet' with Upload Data action if transactions array is empty. LOADING: Shows skeleton placeholders while useTransactions and usePreferences queries resolve. FY SPECIAL BEHAVIOR: When Period B is the current (in-progress) fiscal year, both periods are truncated to the same elapsed-day count so comparison is apples-to-apples (e.g., 2 months of last FY vs 2 months YTD, not 12 months vs 2 months). COMPUTATION: All metrics computed client-side from full transaction ledger (useTransactions returns all transactions), NOT from backend /api/calculations/* or /api/analytics/v2/* endpoints. Page uses plain percentage-change formula (pctChange = (curr - prev) / prev * 100 or 100 if prev=0). PREFERENCE COUPLING: fiscal_year_start_month determines available FY options and which FY is treated as 'current'; changes via useUpdateFiscalYear trigger page refresh via query invalidation. Day counts are always inclusive (inclusive of both start and end dates) to ensure fair daily averages. formatCurrency and formatCurrencyShort use the user's display currency preference. + +--- + +## Year in Review +**Route:** `/year-in-review` · **Reads from:** `useTransactions()`, `useDailySummaries()` (server-side pre-computed daily summaries from `/api/analytics/v2/daily-summaries`), `usePreferences()` for `fiscal_year_start_month` + +Displays an interactive annual financial review with spending/earning heatmaps by day, monthly breakdown charts, and key metrics to highlight spending patterns and savings performance over a full calendar year or fiscal year. + +### What's on the page + +**Top Stat Cards (4-column grid on desktop, 2x2 on mobile):** + +- **Total Spending** (KPI metric) — Sum of all expenses across the selected period, formatted compact; *computed:* `sum(grid[].expense)` aggregated from daily cells +- **Total Earning** (KPI metric) — Sum of all income across the selected period, formatted compact; *computed:* `sum(grid[].income)` aggregated from daily cells +- **Savings Rate** (KPI metric) — Percentage of income saved; *computed:* `((totalIncome - totalExpense) / totalIncome) * 100`, color-coded green (≥20%) or orange (<20%), shows up/down arrow based on sign +- **Daily Average** (KPI metric) — Average spending per day with transactions; *computed:* `totalExpense / daysWithExpense` (only days with expenses in denominator), formatted compact + +**Mode Toggle (above stats):** +Three exclusive tabs toggle between Spending (expenses, red), Earning (income, green), and Savings (net cash flow, blue). The active tab switches which values are displayed on the heatmap and controls the color scheme across all downstream visualizations. + +**Spending/Earning/Net Heatmap (desktop & responsive mobile fallback):** + +- **Heatmap Grid (desktop-only):** 52 weeks × 7 days matrix showing a year at a glance; each day is a clickable 13×13px cell + - **Each Cell (HeatmapCell):** Shows a single day's expense, income, or net (depending on mode); *computed:* intensity level determined by `getIntensityLevel(value / modeMax)` which buckets into 5 intensity levels (0–4) using thresholds [15%, 35%, 60%, 100%]; background color drawn from `heatmapColors[mode][intensityLevel]` (red/green/blue opacity progression); today's date outlined with mode accent color; cell is focusable, screen-reader labeled as `"YYYY-MM-DD: $X.XX spent/earned/net"` or `"YYYY-MM-DD: no activity"` + - **Max Value (modeMax):** The highest daily value in the period for the active mode; used as the denominator for intensity scaling so the heatmap's color range adapts to data spread (e.g., if max expense is $500, a $75 day hits intensity 2) + - **Month Labels:** Positioned at the first Sunday of each month above the grid (derived from `deriveMonthLabels()` scanning for month boundaries on Sundays) + - **Day-of-Week Labels:** Left sidebar shows alternating day abbreviations (Sun, Tue, Thu, Sat) for row context + +- **Mobile Monthly Summary (hidden on `md:` breakpoint):** 3×4 grid of month boxes instead of full-week heatmap; each box shows month abbreviation and `formatCurrencyCompact(abs(monthlyValue))` with background intensity matching the heatmap's colorscale + +- **Day Detail Strip (below heatmap):** On hover/focus, displays: date (formatted `"Fri, 15 Jun 2025"`), Spending (red text), Earning (green text), Savings (blue text if positive, orange if negative), each showing `formatCurrency(amount)`. Empty state on desktop: `"Hover over a day to see details"`, on mobile: `"Tap a month to see details"`. + +**Monthly Breakdown Chart (ComposedChart, 2/3 width on desktop, full width on tablet/mobile):** + +- **Chart Type:** Stacked bar + overlay line combo; X-axis shows month abbreviations (Jan–Dec or FY months), Y-axis stacked values +- **Spending Bar** (red, top): `monthlyBarData[].Spending` = total expenses for that month; labeled with `formatCurrencyShort()` if room +- **Earning Bar** (green, below): `monthlyBarData[].Earning` = total income for that month; labeled with `formatCurrencyShort()` +- **Net Cash Flow Line** (blue dashed overlay): `monthlyBarData[].Net = Earning - Spending`; peaks above bars indicate saving months, troughs between bars indicate overspending months; animates with dots +- *Computed:* `monthlyBarData[i] = { name: MONTHS_SHORT[i], Spending: stats.monthlyExpense[i], Earning: stats.monthlyIncome[i], Net: earning - spending }` +- **Empty State:** If all months have `Spending === 0 && Earning === 0`, shows generic "No data" placeholder + +**Quick Insights Panel (1/3 width on desktop, stacked below chart on mobile, 6 rows + 2 footer sections):** + +- **Best Month (lowest spend)** (sun icon, green) — Month abbreviation where spending was lowest (excluding zero months); *computed:* `MONTHS_SHORT[monthlyExpense.indexOf(Math.min(...monthlyExpense.filter(e => e > 0)))]`, displays "N/A" if no spending months +- **Worst Month (highest spend)** (moon icon, red) — Month abbreviation with max spending; *computed:* `MONTHS_SHORT[monthlyExpense.indexOf(Math.max(...monthlyExpense))]` +- **Longest no-spend streak** (flame icon, orange) — Days with zero expense but ≥1 non-expense transaction (e.g., income-only days); *computed:* `maxStreak` from `accumulateStats()` which walks grid counting consecutive days where `expense === 0 && hasTx === true`; formatted `"${maxStreak} days"`, appears even if zero (shows `"0 days"`) +- **Biggest spending day** (down-arrow icon, red) — Largest single-day expense amount and its date; *computed:* `biggestExpenseDay = { date, amount }` tracked during stats accumulation, formatted `"$X.XXK"` and `"Mon, 15"` (or "N/A" if no spending) +- **Biggest earning day** (up-arrow icon, green) — Largest single-day income and its date; *computed:* `biggestIncomeDay = { date, amount }` similarly accumulated, formatted compact with date subtitle +- **Days with expenses** (bar chart icon, blue) — Count of days where `expense > 0`; *computed:* `"${daysWithExpense} of ${grid.length}"` (denominator is total days in the period) + +**No-Spend Streak Record (footer section, visible if `maxStreak > 0`):** + +- **Dot visualization:** Up to 30 small colored dots (full width) representing the streak; each dot's color transitions through green→blue→purple as position advances; opacity increases from 0.5 to 1.0 across the streak to show intensity buildup +- **Streak count badge** (large bold text, right-aligned) — `${stats.maxStreak} days` in color determined by `getStreakColor()`: green <7d, blue 7–13d, purple ≥14d + +**Total Savings (footer section):** + +- Large bold display: `formatCurrencyCompact(stats.totalSavings)` +- *Computed:* `totalIncome - totalExpense`; color-coded green if positive (prefixed `"+"`), red if negative (prefixed `"-"`) +- Divider above to separate from insights rows + +**Spending by Day of Week (full-width section below monthly chart):** + +- **Chart Type:** Radial/polar radar chart (7-point polygon, one per day Sun–Sat) +- **Spending Overlay** (red, semi-transparent fill): Average daily spending per day-of-week; *computed:* `spending[dayOfWeek] = sum(expense on that day) / count(occurrences of that day)` +- **Earning Overlay** (green, lighter semi-transparent): Average daily earning per day-of-week; same computation with income +- **Insights below chart (2-column grid, if data exists):** + - **Biggest Day** (left, red-tinted): `"${insights.topDay} · $X.XXK/day"` showing the highest-spending day; *computed:* day with max spending value in the series, formatted compact + - **Weekend vs Weekday** (right, neutral): `"+X%"` or `"-X%"` indicating weekend differential; *computed:* `weekendDelta = (weekendAvg - weekdayAvg) / weekdayAvg * 100` where `weekendAvg = (Sun + Sat) / 2` and `weekdayAvg = (Mon..Fri) / 5`, percentage displayed with "on weekends" suffix + +### Controls & notes + +**Time Filter (top-right dropdown):** + +- **View Mode Toggle:** Switches between `'yearly'` (calendar year Jan–Dec) and `'fy'` (fiscal year Apr–Mar or as configured) +- **Year Selector:** Dropdown or spinner to pick calendar year (e.g., 2024, 2025) +- **Fiscal Year Selector:** If FY mode active, shows `"FY YYYY-YY"` label; calendar year extracted from FY label for internal date range calculations +- **Date Range Bounds:** Min/max dates constrain selectable periods based on uploaded transaction data; if no transactions exist in a period, returns "No data" state +- *Controlled by:* `viewMode`, `currentYear`, `currentFY`, `setViewMode`, `setCurrentYear`, `setCurrentFY` + +**Data Range Selection Logic:** + +- If `viewMode === 'fy'`: range starts at `new Date(selectedYear, fiscalYearStartMonth - 1, 1)` and ends at `new Date(selectedYear + 1, fiscalYearStartMonth - 1, 0)` (last day of fiscal year) +- If `viewMode === 'yearly'`: range is `Jan 1 – Dec 31` of selected year +- Range filters applied using `toLocalDateKey()` to preserve timezone-aware comparisons (avoids IST rollback bug with `toISOString()`) + +**Server vs. Client Computation:** + +- **With Daily Summaries (faster path):** If `dailySummaries` array has coverage spanning the selected date range (`summaryDates[0] <= startStr && summaryDates[-1] >= endStr`), uses `aggregateFromDailySummaries()` to build daily totals from pre-computed server endpoint (endpoint: `GET /api/analytics/v2/daily-summaries`) +- **Fallback to Transactions:** If daily summaries unavailable or have gaps, falls back to `aggregateDayTotals()` which walks all transactions and filters by date range, computing per-day sums client-side +- **Decision point in hook:** `const hasCoverage = summaryDates.length > 0 && summaryDates[0] <= startStr && summaryDates[summaryDates.length - 1] >= endStr` + +**Fiscal Year Preference Coupling:** + +- `fiscalYearStartMonth` reads from user preferences (`preferences?.fiscal_year_start_month || default 4` = April) +- Passed to date range calculations so heatmap respects custom FY boundaries +- If preferences unavailable, defaults to April as fiscal year start + +**Empty States:** + +- **No Transactions:** Large card with icon + message `"No transaction data yet"` + `"Upload Data"` button linking to `/upload` +- **No Data for Period:** Individual charts (Monthly Breakdown, Day-of-Week Radar) show `ChartEmptyState` placeholder if all values are zero + +**Hover/Focus Interactions:** + +- Heatmap cells delegate focus/hover events up via `onMouseOver`, `onFocus` to find `data-cell-date` and update `hoveredDay` state +- Day detail strip reactively displays hovered cell's data; on blur/mouse-leave, clears detail +- Keyboard navigation: cells are native `