A modern, minimal personal finance tracking application built with a focus on clarity, scalability, and AI-assisted development.
Spendrift helps users track:
- Daily expenses (Needs vs Wants)
- Monthly budgets and savings targets
- Financial reports (weekly / monthly / yearly)
- Multi-year spending comparisons
- Category breakdowns and analytics
The app is designed as a tracker-based system, where each tracker represents an independent financial workspace with its own currency (e.g., Bangladesh Tracker in BDT, Europe Tracker in EUR).
All features are backed by the real Spendrift API (FastAPI) — see Getting Started.
- TanStack Start — full-stack React framework (SSR + file-based routing)
- React + TypeScript
- TailwindCSS + ShadCN UI (Radix primitives)
- TanStack Query — server-state, caching, and invalidation
- Zod + React Hook Form — schema validation
- Recharts (via ShadCN charts), Sonner (toasts)
- Biome — formatter + linter
- Vitest — unit tests
- Sentry — error monitoring
- Node.js 20+ and pnpm
- A running Spendrift API (FastAPI). The frontend expects it at
http://localhost:8000with the base path/api/v1. Interactive API docs live athttp://localhost:8000/docs.
# 1. Install dependencies
pnpm install
# 2. Create your local env file
cp .env.example .env.local
# then set VITE_API_BASE_URL (default: http://localhost:8000/api/v1)
# 3. Start the dev server (http://localhost:3000)
pnpm dev| Variable | Required | Description |
|---|---|---|
VITE_API_BASE_URL |
✅ | Base URL of the Spendrift API, including the /api/v1 prefix. |
VITE_APP_TITLE |
optional | App title override. |
VITE_SENTRY_* / SENTRY_* |
optional | Sentry monitoring (see .env.example). |
pnpm dev # start the dev server on :3000
pnpm test # run the Vitest unit suite
pnpm check # Biome lint + format check
pnpm build # production build
pnpm start # serve the production buildSpendrift follows a Domain-Driven, Feature-Based Architecture. Each feature is self-contained and split into three layers:
src/features/<feature>/
├── domain/ # types + pure business logic (services.ts)
├── data/ # repository.ts (API calls), dto.ts (wire mapping), queryKeys.ts
└── presentation/ # pages + React Query hooks (use*.ts)
Pages and hooks never call fetch directly. Every feature talks to the API through its data/repository.ts, which is the single swap seam:
Page → presentation/use*.ts (TanStack Query) → data/repository.ts → shared/api/client.ts
Two impedance mismatches are handled at the data/dto.ts boundary so the domain stays clean:
- Money is a Decimal string on the wire (e.g.
"1267.42") ↔numberin the domain. - Casing: API is
snake_case↔ domain iscamelCase.
JWT access + refresh tokens (stored in localStorage) with a single-flight refresh-on-401 retry in shared/api/client.ts. Because tokens aren't readable during SSR, a client-side WorkspaceGate (in routes/__root.tsx) is the auth source of truth.
- Feature-based structure, cohesive domains
- Separation of UI from business logic
- Repositories as the single API seam
- Composition over abstraction; avoid premature optimization
Each tracker is an independent financial context with its own currency:
- 🇧🇩 Bangladesh Tracker (BDT)
- 🇪🇺 Europe Tracker (EUR)
Each tracker owns its expenses, categories, budgets, dashboard, and reports. The active tracker is carried in the URL as ?tracker=<id>.
- Add / edit / delete expenses
- Needs vs Wants tagging
- Search, date-range, type, and category filtering
- Per-category management (with safe "reassign to Uncategorized" on delete)
- Bulk entry — "Add multiple" opens a wide modal with a shared date
and a row grid (
useFieldArray); saves in parallel viaPromise.allSettledwith per-row failure retry - AI smart paste — collapsible "Smart paste" section above the bulk
grid turns free-form text into candidate rows via
POST /ai/parse-expenses. Parsed rows always land back in the review grid — the AI never writes to the API directly
- One budget per tracker per current month
- Monthly limit + savings target
- Remaining balance and a savings-health indicator (green / yellow / red)
- Previous months become read-only history
- Per-category budget alerts — a dismissible alert banner on the Dashboard
surfaces categories that have crossed their warning / exceeded threshold for
the selected month. Backend-driven (
GET /trackers/:id/budget-alerts) and gated behind the user's "Budget alerts" preference.
- Current-month spend, expense count, and budget remaining
- Needs-vs-Wants split and top categories
- Cashflow trend + recent expenses
- Honors the global month selector — pick a past month and every stat follows
- Catch-up recency — ambient "Last entry: today / yesterday" line when caught up; a calm primary-tinted nudge (with a one-click "Catch up" button that opens the bulk-add modal) once 2+ days have passed since the last logged expense
- Weekly / monthly / yearly spending
- Category breakdown and Needs-vs-Wants
- Year-over-year comparison
- Total / min / max / average analytics
- Custom calendar date ranges
Server-backed per-user toggles (/preferences):
- Budget alerts — enable/disable the dashboard alert banner
- Weekly summary — reserved flag (feature pending)
- Round amounts — round money display app-wide through
useFormatCurrency()
The Settings page loads preferences via TanStack Query with optimistic updates (rollback + toast on error).
Spendrift is designed to feel minimal, calm, modern, and data-focused — inspired by Linear, Notion, and modern fintech dashboards.
- Dark theme first
- High readability and clear hierarchy
- Meaningful colors (not decorative)
- Reduced visual noise
An emerald-forward palette defined as oklch design tokens in src/styles.css (light and dark variants). The default is dark; a stored preference wins. Charts use ShadCN's chart components.
Unit tests cover the pure domain/services functions (budgets, expenses, reports) with Vitest:
pnpm testTests run in an isolated vitest.config.ts (node environment, @/ alias) so they skip the full app plugin chain.
src/
├── features/ # dashboard, expenses, budgets, reports, trackers
│ └── <feature>/ # domain/ · data/ · presentation/
├── shared/
│ ├── api/ # apiFetch client + token storage
│ ├── ui/ # shared UI (AppSidebar, StatCard, ThemeToggle…)
│ ├── hooks/
│ └── utils/
├── components/ui/ # ShadCN-generated primitives
├── routes/ # TanStack Start file-based routes
└── styles.css
- Plan the feature structure first
- Break it into small steps
- Implement incrementally behind the repository seam
- Verify (tests + run the app)
- Commit using conventional commits
feat(expenses): add expense list UI
fix(budget): correct remaining balance calculation
refactor(api): back reports with the real API
This project leans on AI for architecture planning, code explanation, UI exploration, and refactoring — used as a mentor and assistant, not an autopilot. The goal is learning by building, not blind generation.
- Investment tracking
- Loan management
- AI-powered financial insights
- Multi-user SaaS support
- Mobile optimization
- SSR auth via httpOnly cookies (currently a client-side gate)
Includes the (new) Budget alerts banner at the top — appears only when the user's
preferences.budgetAlertsflag is enabled and the selected month has categories crossing the warning / exceeded threshold. The Catch-up recency line/nudge sits just below the header.
When the capture run finds active alerts, it also writes
dashboard-alert.pngso the banner is documented with real data instead of a mock.
Toolbar now shows Add multiple (bulk entry) alongside the original Add expense button.
Shared date picker +
useFieldArrayrow grid. Blank starter rows are silently pruned before validation; failed rows stay in the grid for retry (parallelPromise.allSettledoverPOST /expenses).
Collapsible section above the bulk grid. Pasting free text and clicking Parse into rows sends it to
POST /ai/parse-expenses; results are always appended to the review grid — the AI never persists directly.
The dashboard's catch-up banner deep-links to
/expenses?bulk=1, which auto-opens the bulk modal once and strips the search param.
The Preferences card is now backed by
GET/PUT /preferences(no longer localStorage). Toggling instantly reflects everywhereuseFormatCurrency()is used when Round amounts is on.
📸 To re-capture the screenshots in this section (e.g. after a UI pass), see
docs/screenshots/CAPTURE.mdand runpnpm dlx tsx scripts/capture-screenshots.ts.
Copyright (c) 2026 Dipto Karmakar
Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the LICENSE file for full terms.
In plain English:
- You may study and use this code for personal/educational purposes
- If you modify and distribute it, you must open-source your version under AGPL-3.0
- You may not use this in a commercial product or SaaS without written permission from the author
- You must credit the original author (Dipto Karmakar) in any derivative work
For commercial licensing inquiries: diptokmk47@gmail.com
Dipto Karmakar — Frontend engineer focused on React / TypeScript, domain-driven design, high-performance UI, and AI-assisted workflows. Spendrift is a personal initiative to explore modern fintech UX and scalable SaaS architecture.










