diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..09d6b11 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "budget-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 7654 + } + ] +} diff --git a/package-lock.json b/package-lock.json index bd34d4b..3e5b11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "budget", "version": "0.1.0", "dependencies": { + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "next": "16.1.1", "react": "19.2.0", "react-dom": "19.2.0" @@ -1488,6 +1490,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2421,6 +2429,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index aaf84e9..6749b6d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@types/bcryptjs": "^2.4.6", + "bcryptjs": "^3.0.3", "next": "16.1.1", "react": "19.2.0", "react-dom": "19.2.0" diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..fac75b8 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "Capsule - Smart Envelope Budgeting", + "short_name": "Capsule", + "description": "Track spending with smart envelopes and auto-income allocation", + "start_url": "/", + "display": "standalone", + "background_color": "#f9fafb", + "theme_color": "#1E73BE", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/images/capsule-logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["finance", "productivity"], + "lang": "en" +} diff --git a/src/app/api/auth/delete-account/route.ts b/src/app/api/auth/delete-account/route.ts index bf2baf1..fd0e09d 100644 --- a/src/app/api/auth/delete-account/route.ts +++ b/src/app/api/auth/delete-account/route.ts @@ -2,24 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { FileStorageService } from '@/lib/fileStorage'; export async function POST(request: NextRequest) { + const token = request.headers.get('x-session-token'); + if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const session = await FileStorageService.validateSession(token); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { - const userId = request.headers.get('x-user-id'); - - if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 401 } - ); - } - - // Delete user account and all data - await FileStorageService.deleteUser(userId); - + await FileStorageService.deleteUser(session.userId, token); return NextResponse.json({ success: true, message: 'Account deleted successfully' }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Account deletion failed' }, - { status: 500 } - ); + return NextResponse.json({ error: error.message || 'Account deletion failed' }, { status: 500 }); } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 9518afc..a6a373c 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -6,22 +6,13 @@ export async function POST(request: NextRequest) { const { email, password } = await request.json(); if (!email || !password) { - return NextResponse.json( - { error: 'Email and password are required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); } - const user = await FileStorageService.loginUser(email, password); + const result = await FileStorageService.loginUser(email, password); - return NextResponse.json({ - success: true, - user - }); + return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Login failed' }, - { status: 401 } - ); + return NextResponse.json({ error: error.message || 'Login failed' }, { status: 401 }); } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ec7be03 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { FileStorageService } from '@/lib/fileStorage'; + +export async function POST(request: NextRequest) { + const token = request.headers.get('x-session-token'); + if (token) await FileStorageService.deleteSession(token); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 0b2f89b..a3a1abc 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -6,22 +6,14 @@ export async function POST(request: NextRequest) { const { email, password } = await request.json(); if (!email || !password) { - return NextResponse.json( - { error: 'Email and password are required' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); } - const user = await FileStorageService.registerUser(email, password); + const result = await FileStorageService.registerUser(email, password); - return NextResponse.json({ - success: true, - user - }); + return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Registration failed' }, - { status: 400 } - ); + const status = error.message === 'User already exists' ? 409 : 400; + return NextResponse.json({ error: error.message || 'Registration failed' }, { status }); } } diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts index 36b9cb0..74c8a16 100644 --- a/src/app/api/auth/reset-password/route.ts +++ b/src/app/api/auth/reset-password/route.ts @@ -1,34 +1,35 @@ import { NextRequest, NextResponse } from 'next/server'; import { FileStorageService } from '@/lib/fileStorage'; +// POST /api/auth/reset-password/request — generate a reset token for an email +// POST /api/auth/reset-password — consume a token and set a new password export async function POST(request: NextRequest) { try { - const { email, code, newPassword } = await request.json(); + const body = await request.json(); - if (!email || !newPassword) { - return NextResponse.json( - { error: 'Email and new password are required' }, - { status: 400 } - ); + if (body.action === 'request') { + const { email } = body; + if (!email) return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + + try { + const token = await FileStorageService.generatePasswordResetToken(email); + // In production this token would be emailed. For self-hosted use we return it directly. + return NextResponse.json({ success: true, resetToken: token, message: 'Use the resetToken to set your new password.' }); + } catch { + // Return success either way to avoid email enumeration + return NextResponse.json({ success: true, message: 'If that email exists, a reset token has been generated.' }); + } } - // In production, validate that code matches a sent reset token - // For now, we accept any code for testing purposes - if (!code) { - return NextResponse.json( - { error: 'Reset code is required' }, - { status: 400 } - ); + // action === 'reset' (default) + const { token, newPassword } = body; + if (!token || !newPassword) { + return NextResponse.json({ error: 'Token and new password are required' }, { status: 400 }); } - // Reset password for user - await FileStorageService.resetPassword(email, newPassword); - + await FileStorageService.resetPassword(token, newPassword); return NextResponse.json({ success: true, message: 'Password reset successfully' }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Password reset failed' }, - { status: 500 } - ); + return NextResponse.json({ error: error.message || 'Password reset failed' }, { status: 400 }); } } diff --git a/src/app/api/data/route.ts b/src/app/api/data/route.ts index c51024a..8daeb8b 100644 --- a/src/app/api/data/route.ts +++ b/src/app/api/data/route.ts @@ -1,52 +1,33 @@ import { NextRequest, NextResponse } from 'next/server'; import { FileStorageService } from '@/lib/fileStorage'; +async function authenticate(request: NextRequest) { + const token = request.headers.get('x-session-token'); + if (!token) return null; + return FileStorageService.validateSession(token); +} + export async function GET(request: NextRequest) { + const session = await authenticate(request); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { - const userId = request.headers.get('x-user-id'); - - if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 401 } - ); - } - - const data = await FileStorageService.loadUserData(userId); - - return NextResponse.json({ - success: true, - data - }); + const data = await FileStorageService.loadUserData(session.userId); + return NextResponse.json({ success: true, data }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Failed to load data' }, - { status: 500 } - ); + return NextResponse.json({ error: error.message || 'Failed to load data' }, { status: 500 }); } } export async function POST(request: NextRequest) { + const session = await authenticate(request); + if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + try { - const userId = request.headers.get('x-user-id'); - - if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 401 } - ); - } - const data = await request.json(); - await FileStorageService.saveUserData(userId, data); - - return NextResponse.json({ - success: true - }); + await FileStorageService.saveUserData(session.userId, data); + return NextResponse.json({ success: true }); } catch (error: any) { - return NextResponse.json( - { error: error.message || 'Failed to save data' }, - { status: 500 } - ); + return NextResponse.json({ error: error.message || 'Failed to save data' }, { status: 500 }); } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c35014d..03121cc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,6 +19,12 @@ export const metadata: Metadata = { icons: { icon: "/capsule-icon.svg", }, + manifest: "/manifest.json", + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Capsule", + }, }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index b3f1102..616389b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,24 +1,22 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { AuthService } from '../lib/authService'; import { DataService } from '../lib/dataService'; import DataInput from '../components/DataInput'; import TransactionEdit from '../components/TransactionEdit'; import EnvelopeCreate from '../components/EnvelopeCreate'; import EnvelopeEdit from '../components/EnvelopeEdit'; -import DataExport from '../components/DataExport'; import AuthModal from '../components/AuthModal'; import SetupWizard from '../components/SetupWizard'; import AccountManagement from '../components/AccountManagement'; import DashboardCharts from '../components/DashboardCharts'; import Settings from '../components/Settings'; import GetPaid from '../components/GetPaid'; +import GoalsManager from '../components/GoalsManager'; +import TransactionRulesManager from '../components/TransactionRulesManager'; -interface User { - userId: string; - email: string; -} +interface User { userId: string; email: string } interface Account { id: string; @@ -40,8 +38,9 @@ interface Envelope { spent: number; color: string; accountId: string; - incomeAllocation?: number; // Percentage (0-100) or fixed amount if negative - incomeAllocationType?: 'percentage' | 'fixed'; // How to interpret incomeAllocation + incomeAllocation?: number; + incomeAllocationType?: 'percentage' | 'fixed'; + rollover?: boolean; } interface Transaction { @@ -51,17 +50,52 @@ interface Transaction { description: string; date: Date; accountId: string; + isRecurring?: boolean; + recurringFrequency?: 'weekly' | 'biweekly' | 'monthly' | 'yearly'; + lastAppliedDate?: string; } -type ViewMode = 'dashboard' | 'accounts' | 'transactions' | 'envelopes' | 'settings'; +export interface Goal { + id: string; + name: string; + targetAmount: number; + currentAmount: number; + accountId: string; + targetDate?: string; + color: string; +} + +export interface TransactionRule { + id: string; + keyword: string; + envelopeId: string; + matchCase: boolean; +} + +type ViewMode = 'dashboard' | 'accounts' | 'transactions' | 'envelopes' | 'goals' | 'rules' | 'settings'; +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; const liabilityTypes: Array = ['credit_card', 'mortgage', 'loan']; -const normalizeBalanceByType = (type: Account['type'], balance: number): number => { - if (liabilityTypes.includes(type)) { - return -Math.abs(balance || 0); + +function normalizeBalanceByType(type: Account['type'], balance: number): number { + return liabilityTypes.includes(type) ? -Math.abs(balance || 0) : (balance || 0); +} + +function applyRulesToTransaction( + tx: Transaction, + rules: TransactionRule[], + envelopes: Envelope[], +): Transaction { + if (tx.envelopeId || tx.amount >= 0) return tx; + for (const rule of rules) { + const haystack = rule.matchCase ? tx.description : tx.description.toLowerCase(); + const needle = rule.matchCase ? rule.keyword : rule.keyword.toLowerCase(); + if (haystack.includes(needle) && envelopes.some(e => e.id === rule.envelopeId)) { + return { ...tx, envelopeId: rule.envelopeId }; + } } - return balance || 0; -}; + return tx; +} export default function BudgetDashboard() { const [currentView, setCurrentView] = useState('dashboard'); @@ -70,17 +104,29 @@ export default function BudgetDashboard() { const [showAuthModal, setShowAuthModal] = useState(false); const [showSetupWizard, setShowSetupWizard] = useState(false); const [showGetPaidModal, setShowGetPaidModal] = useState(false); + const [saveStatus, setSaveStatus] = useState('idle'); const [accounts, setAccounts] = useState([]); const [envelopes, setEnvelopes] = useState([]); const [transactions, setTransactions] = useState([]); + const [goals, setGoals] = useState([]); + const [transactionRules, setTransactionRules] = useState([]); const [setupCompleted, setSetupCompleted] = useState(false); - // Dashboard section order and visibility + // Transaction filters + const [txSearch, setTxSearch] = useState(''); + const [txDateFrom, setTxDateFrom] = useState(''); + const [txDateTo, setTxDateTo] = useState(''); + const [txAccountFilter, setTxAccountFilter] = useState(''); + const [txEnvelopeFilter, setTxEnvelopeFilter] = useState(''); + + // Dashboard customization const [dashboardSections, setDashboardSections] = useState([ { id: 'summaryCards', label: 'Summary Cards', visible: true }, { id: 'charts', label: 'Analytics Charts', visible: true }, + { id: 'insights', label: 'Spending Insights', visible: true }, { id: 'budgetProgress', label: 'Budget Progress', visible: true }, + { id: 'goals', label: 'Goals', visible: true }, { id: 'quickActions', label: 'Quick Actions', visible: true }, { id: 'recentTransactions', label: 'Recent Transactions', visible: true }, ]); @@ -91,347 +137,339 @@ export default function BudgetDashboard() { const [editingTransaction, setEditingTransaction] = useState(null); const [showCreateEnvelope, setShowCreateEnvelope] = useState(false); const [editingEnvelope, setEditingEnvelope] = useState(null); + const [showGoalsManager, setShowGoalsManager] = useState(false); + + // ── Derived helpers ────────────────────────────────────────────────────────── + + const calculateEnvelopeSpending = useCallback((envs: Envelope[], txns: Transaction[]): Envelope[] => + envs.map(envelope => { + const spent = txns + .filter(t => t.envelopeId === envelope.id && t.amount < 0) + .reduce((sum, t) => sum + Math.abs(t.amount), 0); + return { ...envelope, spent }; + }), []); + + const calculateAccountBalances = useCallback((accs: Account[], txns: Transaction[]): Account[] => { + const map = new Map(); + txns.forEach(t => map.set(t.accountId, (map.get(t.accountId) ?? 0) + t.amount)); + return accs.map(acc => { + const base = normalizeBalanceByType(acc.type, acc.startingBalance ?? acc.balance ?? 0); + return { ...acc, startingBalance: base, balance: base + (map.get(acc.id) ?? 0) }; + }); + }, []); + + // ── Auth & data load ───────────────────────────────────────────────────────── - // Initialize auth listener useEffect(() => { - const unsubscribe = AuthService.onAuthStateChanged(async (user) => { - setUser(user); - if (user) { - // Set user ID for DataService - DataService.setUserId(user.userId); - - // Load user data + const unsub = AuthService.onAuthStateChanged(async (u) => { + setUser(u); + if (u) { + DataService.setUserId(u.userId); try { const userData = await DataService.loadUserData(); if (userData) { - // Convert date strings to Date objects - const transactionsWithDates = (userData.transactions || []).map((transaction) => ({ - ...transaction, - date: typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date, + const txnsWithDates = (userData.transactions || []).map((t: any) => ({ + ...t, + date: typeof t.date === 'string' ? new Date(t.date) : t.date, })); - - const accountsWithBaseline = (userData.accounts || []).map((account) => { - const accountTransactions = transactionsWithDates.filter((transaction) => transaction.accountId === account.id); - const transactionTotal = accountTransactions.reduce((sum, transaction) => sum + transaction.amount, 0); - const inferredBase = account.startingBalance ?? account.balance - transactionTotal; - const startingBalance = normalizeBalanceByType(account.type, inferredBase); - return { ...account, startingBalance, balance: startingBalance + transactionTotal }; + const accsWithBase = (userData.accounts || []).map((acc: any) => { + const txTotal = txnsWithDates + .filter((t: Transaction) => t.accountId === acc.id) + .reduce((s: number, t: Transaction) => s + t.amount, 0); + const base = normalizeBalanceByType(acc.type, acc.startingBalance ?? acc.balance - txTotal); + return { ...acc, startingBalance: base, balance: base + txTotal }; }); - - setAccounts(accountsWithBaseline); + setAccounts(accsWithBase); setEnvelopes(userData.envelopes || []); - setTransactions(transactionsWithDates); + setTransactions(txnsWithDates); + setGoals(userData.goals || []); + setTransactionRules(userData.transactionRules || []); setSetupCompleted(userData.setupCompleted || false); - - // Show setup wizard if setup is not completed OR if there are no accounts - const shouldShowWizard = !(userData.setupCompleted || false) || accountsWithBaseline.length === 0; - setShowSetupWizard(shouldShowWizard); + setShowSetupWizard(!(userData.setupCompleted) || accsWithBase.length === 0); + + // Auto-apply due recurring transactions + autoApplyRecurring(txnsWithDates); } else { - // New user - show setup wizard setShowSetupWizard(true); } - } catch (error) { - console.error('Error loading user data:', error); - // On error, assume new user and show setup wizard + } catch { setShowSetupWizard(true); } } else { - // Clear data when user logs out DataService.setUserId(null); - setAccounts([]); - setEnvelopes([]); - setTransactions([]); - setSetupCompleted(false); - setShowSetupWizard(false); + setAccounts([]); setEnvelopes([]); setTransactions([]); + setGoals([]); setTransactionRules([]); + setSetupCompleted(false); setShowSetupWizard(false); } setLoading(false); }); - - return () => unsubscribe(); + return () => unsub(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Save data whenever data changes (with debouncing to prevent race conditions) + // ── Auto-save ──────────────────────────────────────────────────────────────── + useEffect(() => { - if (user && (((accounts || []).length > 0 || (envelopes || []).length > 0 || (transactions || []).length > 0 || setupCompleted))) { - const debounceTimer = setTimeout(async () => { - try { - // Recalculate envelope spent amounts before saving - const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions); - // Recalculate account balances before saving - const updatedAccounts = calculateAccountBalances(accounts, transactions); - - await DataService.saveUserData({ - accounts: updatedAccounts, - envelopes: updatedEnvelopes, - transactions, - setupCompleted - }); - } catch (error) { - console.error('Error saving user data:', error); - } - }, 1000); // Debounce saves by 1 second - - return () => clearTimeout(debounceTimer); - } - }, [user, accounts, envelopes, transactions, setupCompleted]); + if (!user) return; + if (!accounts.length && !envelopes.length && !transactions.length && !setupCompleted) return; - // Calculate envelope spending from transactions - const calculateEnvelopeSpending = (envelopes: Envelope[], transactions: Transaction[]): Envelope[] => { - return envelopes.map(envelope => { - const envelopeTransactions = transactions.filter(t => t.envelopeId === envelope.id); - const spent = envelopeTransactions.reduce((sum, t) => { - // Track spending from negative amounts (expenses) - // Positive amounts are income allocations to the envelope - return sum + (t.amount < 0 ? Math.abs(t.amount) : 0); - }, 0); - // Keep allocated as-is; income allocations are stored in transactions, not in allocated - return { ...envelope, spent }; - }); - }; - - // Calculate account balances from transactions - const calculateAccountBalances = (accounts: Account[], transactions: Transaction[]): Account[] => { - const accountTransactionMap = new Map(); - transactions.forEach((transaction) => { - if (!accountTransactionMap.has(transaction.accountId)) { - accountTransactionMap.set(transaction.accountId, []); + setSaveStatus('saving'); + const timer = setTimeout(async () => { + try { + const updatedEnvelopes = calculateEnvelopeSpending(envelopes, transactions); + const updatedAccounts = calculateAccountBalances(accounts, transactions); + await DataService.saveUserData({ + accounts: updatedAccounts, + envelopes: updatedEnvelopes, + transactions, + goals, + transactionRules, + setupCompleted, + } as any); + setSaveStatus('saved'); + setTimeout(() => setSaveStatus('idle'), 2000); + } catch { + setSaveStatus('error'); } - accountTransactionMap.get(transaction.accountId)!.push(transaction); + }, 1000); + + return () => clearTimeout(timer); + }, [user, accounts, envelopes, transactions, goals, transactionRules, setupCompleted, + calculateEnvelopeSpending, calculateAccountBalances]); + + // ── Recurring transactions ─────────────────────────────────────────────────── + + const autoApplyRecurring = (txns: Transaction[]) => { + const now = new Date(); + const due = txns.filter(t => { + if (!t.isRecurring || !t.recurringFrequency) return false; + const last = t.lastAppliedDate ? new Date(t.lastAppliedDate) : t.date; + const next = new Date(last); + if (t.recurringFrequency === 'weekly') next.setDate(next.getDate() + 7); + else if (t.recurringFrequency === 'biweekly') next.setDate(next.getDate() + 14); + else if (t.recurringFrequency === 'monthly') next.setMonth(next.getMonth() + 1); + else if (t.recurringFrequency === 'yearly') next.setFullYear(next.getFullYear() + 1); + return next <= now; }); - return accounts.map((account) => { - const accountTransactions = accountTransactionMap.get(account.id) || []; - const transactionTotal = accountTransactions.reduce((sum, transaction) => sum + transaction.amount, 0); - const baseBalance = normalizeBalanceByType(account.type, account.startingBalance ?? account.balance ?? 0); - return { ...account, startingBalance: baseBalance, balance: baseBalance + transactionTotal }; + if (due.length === 0) return; + + const newTxns: Transaction[] = due.map(t => ({ + ...t, + id: crypto.randomUUID(), + date: new Date(), + lastAppliedDate: new Date().toISOString(), + })); + + setTransactions(prev => { + const updated = prev.map(t => due.find(d => d.id === t.id) + ? { ...t, lastAppliedDate: new Date().toISOString() } + : t); + return [...updated, ...newTxns]; }); }; - const handleTransactionsAdded = (newTransactions: Transaction[]) => { - const allTransactions = [...newTransactions]; + // ── Event handlers ─────────────────────────────────────────────────────────── - // Process income transactions - automatically allocate to envelopes - // BUT: only if allocations weren't already created by GetPaid - newTransactions.forEach(transaction => { - if (transaction.amount > 0 && !transaction.envelopeId) { - // Check if this income already has corresponding allocations in newTransactions - const hasExistingAllocations = newTransactions.some( - t => t.accountId === transaction.accountId && - t.envelopeId && - t.description.includes('→') - ); + const handleTransactionsAdded = (newTxns: Transaction[]) => { + // Apply auto-categorization rules + const ruled = newTxns.map(t => applyRulesToTransaction(t, transactionRules, envelopes)); + const all = [...ruled]; - // Only create auto-allocations if none exist - if (!hasExistingAllocations) { - // This is income - allocate to envelopes based on custom settings or proportionally - const accountEnvelopes = envelopes.filter(env => env.accountId === transaction.accountId); - const envelopesWithAllocation = accountEnvelopes.filter(env => env.incomeAllocation && env.incomeAllocationType); + ruled.forEach(tx => { + if (tx.amount > 0 && !tx.envelopeId) { + const accEnvs = envelopes.filter(e => e.accountId === tx.accountId); + const withAlloc = accEnvs.filter(e => e.incomeAllocation && e.incomeAllocationType); + const hasFixed = withAlloc.some(e => e.incomeAllocationType === 'fixed'); + const hasPerc = withAlloc.some(e => e.incomeAllocationType === 'percentage'); + const source = withAlloc.length > 0 && !(hasFixed && hasPerc) ? withAlloc : []; - if (envelopesWithAllocation.length > 0) { - // Validate allocations before processing - const validation = validateIncomeAllocation(envelopesWithAllocation, transaction.amount); - if (!validation.isValid) { - console.warn('Invalid income allocation detected, falling back to proportional'); - // Fall back to proportional allocation if validation fails - const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); - if (accountEnvelopes.length > 0 && totalAllocated > 0) { - accountEnvelopes.forEach(envelope => { - const proportion = envelope.allocated / totalAllocated; - const allocatedAmount = transaction.amount * proportion; - if (allocatedAmount > 0) { - allTransactions.push({ - id: crypto.randomUUID(), - envelopeId: envelope.id, - amount: allocatedAmount, - description: `Income allocation - ${envelope.name}`, - date: transaction.date, - accountId: transaction.accountId, - }); - } - }); - } - } else { - // Use custom allocation settings - envelopesWithAllocation.forEach(envelope => { - let allocatedAmount = 0; - if (envelope.incomeAllocationType === 'percentage') { - allocatedAmount = transaction.amount * (envelope.incomeAllocation! / 100); - } else if (envelope.incomeAllocationType === 'fixed') { - allocatedAmount = Math.min(envelope.incomeAllocation!, transaction.amount); - } - - if (allocatedAmount > 0) { - allTransactions.push({ - id: crypto.randomUUID(), - envelopeId: envelope.id, - amount: allocatedAmount, - description: `Income allocation - ${envelope.name}`, - date: transaction.date, - accountId: transaction.accountId, - }); - } + if (source.length > 0) { + source.forEach(env => { + const amt = env.incomeAllocationType === 'percentage' + ? tx.amount * (env.incomeAllocation! / 100) + : Math.min(env.incomeAllocation!, tx.amount); + if (amt > 0) all.push({ + id: crypto.randomUUID(), envelopeId: env.id, amount: amt, + description: `Income → ${env.name}`, date: tx.date, accountId: tx.accountId, + }); + }); + } else { + const totalAlloc = accEnvs.reduce((s, e) => s + e.allocated, 0); + if (accEnvs.length > 0 && totalAlloc > 0) { + accEnvs.forEach(env => { + const amt = tx.amount * (env.allocated / totalAlloc); + if (amt > 0) all.push({ + id: crypto.randomUUID(), envelopeId: env.id, amount: amt, + description: `Income → ${env.name}`, date: tx.date, accountId: tx.accountId, }); - } - } else { - // No specific envelope allocation found, fall back to proportional allocation - const totalAllocated = accountEnvelopes.reduce((sum, env) => sum + env.allocated, 0); - if (accountEnvelopes.length > 0 && totalAllocated > 0) { - accountEnvelopes.forEach(envelope => { - const proportion = envelope.allocated / totalAllocated; - const allocatedAmount = transaction.amount * proportion; - if (allocatedAmount > 0) { - allTransactions.push({ - id: crypto.randomUUID(), - envelopeId: envelope.id, - amount: allocatedAmount, - description: `Income allocation - ${envelope.name}`, - date: transaction.date, - accountId: transaction.accountId, - }); - } - }); - } + }); } } } }); - // Add all processed transactions at once and immediately recompute derived balances - setTransactions(prevTransactions => [...prevTransactions, ...allTransactions]); - - // Recalculate envelopes and accounts with the new transactions - const updatedTransactions = [...transactions, ...allTransactions]; - setEnvelopes(calculateEnvelopeSpending(envelopes, updatedTransactions)); - setAccounts(calculateAccountBalances(accounts, updatedTransactions)); - }; - - const validateIncomeAllocation = (envelopes: Envelope[], totalAmount: number) => { - let fixedTotal = 0; - let percentageTotal = 0; - let hasFixed = false; - let hasPercentage = false; - - envelopes.forEach(envelope => { - if (envelope.incomeAllocationType === 'fixed') { - fixedTotal += envelope.incomeAllocation!; - hasFixed = true; - } else if (envelope.incomeAllocationType === 'percentage') { - percentageTotal += envelope.incomeAllocation!; - hasPercentage = true; - } + setTransactions(prev => { + const next = [...prev, ...all]; + setEnvelopes(calculateEnvelopeSpending(envelopes, next)); + setAccounts(calculateAccountBalances(accounts, next)); + return next; }); - - // Check for conflicting allocation types - if (hasFixed && hasPercentage) { - return { isValid: false, message: 'Conflicting allocation types (fixed and percentage) detected.' }; - } - - // Validate fixed allocations - allow partial allocation (don't require exact match) - if (hasFixed && fixedTotal > totalAmount) { - return { isValid: false, message: 'Fixed allocations exceed the total amount.' }; - } - - // Validate percentage allocations - if (hasPercentage && (percentageTotal < 0 || percentageTotal > 100)) { - return { isValid: false, message: 'Percentage allocations must be between 0 and 100.' }; - } - - return { isValid: true }; }; const handleSignOut = async () => { setLoading(true); - try { - await AuthService.signOut(); - } catch (error) { - console.error('Error signing out:', error); - } + await AuthService.signOut().catch(() => {}); setLoading(false); }; - const handleAuthSuccess = () => { - setShowAuthModal(false); - }; - - const handleSetupComplete = (accounts: Account[], envelopes: Envelope[]) => { - const accountsWithBaseline = accounts.map((account) => { - const baseBalance = normalizeBalanceByType(account.type, account.startingBalance ?? account.balance); - return { ...account, startingBalance: baseBalance, balance: baseBalance }; + const handleSetupComplete = (accs: Account[], envs: Envelope[]) => { + const accsWithBase = accs.map(a => { + const base = normalizeBalanceByType(a.type, a.startingBalance ?? a.balance); + return { ...a, startingBalance: base, balance: base }; }); - setAccounts(accountsWithBaseline); - setEnvelopes(envelopes); + setAccounts(accsWithBase); + setEnvelopes(envs); setTransactions([]); setSetupCompleted(true); setShowSetupWizard(false); }; - const handleAccountUpdate = (updatedAccount: Account) => { - setAccounts((prevAccounts) => { - const baseBalance = normalizeBalanceByType(updatedAccount.type, updatedAccount.startingBalance ?? updatedAccount.balance); - const nextAccounts = prevAccounts.map((account) => - account.id === updatedAccount.id ? { ...updatedAccount, startingBalance: baseBalance } : account, - ); - return calculateAccountBalances(nextAccounts, transactions); + const handleAccountUpdate = (updated: Account) => { + setAccounts(prev => { + const base = normalizeBalanceByType(updated.type, updated.startingBalance ?? updated.balance); + const next = prev.map(a => a.id === updated.id ? { ...updated, startingBalance: base } : a); + return calculateAccountBalances(next, transactions); }); }; - const handleAccountAdd = (newAccount: Account) => { - setAccounts((prevAccounts) => { - const baseBalance = normalizeBalanceByType(newAccount.type, newAccount.startingBalance ?? newAccount.balance); - const nextAccounts = [...prevAccounts, { ...newAccount, startingBalance: baseBalance }]; - return calculateAccountBalances(nextAccounts, transactions); + const handleAccountAdd = (newAcc: Account) => { + setAccounts(prev => { + const base = normalizeBalanceByType(newAcc.type, newAcc.startingBalance ?? newAcc.balance); + return calculateAccountBalances([...prev, { ...newAcc, startingBalance: base }], transactions); }); }; - const handleAccountDelete = (accountId: string) => { - setAccounts(prevAccounts => prevAccounts.filter(acc => acc.id !== accountId)); - }; - - const handleEnvelopeUpdate = (updatedEnvelope: Envelope) => { - setEnvelopes(prevEnvelopes => prevEnvelopes.map(env => env.id === updatedEnvelope.id ? updatedEnvelope : env)); - }; - - const handleTransactionEdit = (updatedTransaction: Transaction) => { - setTransactions(prevTransactions => { - const newTransactions = prevTransactions.map(tx => tx.id === updatedTransaction.id ? updatedTransaction : tx); - setEnvelopes(calculateEnvelopeSpending(envelopes, newTransactions)); - setAccounts(calculateAccountBalances(accounts, newTransactions)); - return newTransactions; + const handleTransactionEdit = (updated: Transaction) => { + setTransactions(prev => { + const next = prev.map(t => t.id === updated.id ? updated : t); + setEnvelopes(calculateEnvelopeSpending(envelopes, next)); + setAccounts(calculateAccountBalances(accounts, next)); + return next; }); }; - const handleTransactionDelete = (transactionId: string) => { - setTransactions(prevTransactions => { - const newTransactions = prevTransactions.filter(tx => tx.id !== transactionId); - setEnvelopes(calculateEnvelopeSpending(envelopes, newTransactions)); - setAccounts(calculateAccountBalances(accounts, newTransactions)); - return newTransactions; + const handleTransactionDelete = (id: string) => { + setTransactions(prev => { + const next = prev.filter(t => t.id !== id); + setEnvelopes(calculateEnvelopeSpending(envelopes, next)); + setAccounts(calculateAccountBalances(accounts, next)); + return next; }); }; - const handleCreateEnvelope = (newEnvelope: Envelope) => { - setEnvelopes(prevEnvelopes => [...prevEnvelopes, newEnvelope]); - }; + // ── Filtered transactions ──────────────────────────────────────────────────── - const handleDeleteEnvelope = (envelopeId: string) => { - // Remove envelope and unassign any transactions linked to it - setEnvelopes(prevEnvelopes => prevEnvelopes.filter(env => env.id !== envelopeId)); - // Unassign transactions from deleted envelope (don't delete transactions) - setTransactions(prevTransactions => prevTransactions.map(tx => - tx.envelopeId === envelopeId ? { ...tx, envelopeId: undefined } : tx - )); - }; + const filteredTransactions = transactions.filter(t => { + if (txSearch && !t.description.toLowerCase().includes(txSearch.toLowerCase())) return false; + if (txAccountFilter && t.accountId !== txAccountFilter) return false; + if (txEnvelopeFilter && t.envelopeId !== txEnvelopeFilter) return false; + const d = t.date instanceof Date ? t.date : new Date(t.date); + if (txDateFrom && d < new Date(txDateFrom)) return false; + if (txDateTo && d > new Date(txDateTo + 'T23:59:59')) return false; + return true; + }).slice().sort((a, b) => { + const da = a.date instanceof Date ? a.date : new Date(a.date); + const db = b.date instanceof Date ? b.date : new Date(b.date); + return db.getTime() - da.getTime(); + }); + + // Running balance (newest-first list — show balance before each transaction) + const runningBalances = (() => { + const sorted = [...transactions].sort((a, b) => { + const da = a.date instanceof Date ? a.date : new Date(a.date); + const db = b.date instanceof Date ? b.date : new Date(b.date); + return da.getTime() - db.getTime(); + }); + const balMap = new Map(); + sorted.forEach(t => balMap.set(t.id, (balMap.get(t.id) ?? 0))); + let running = 0; + const result = new Map(); + sorted.forEach(t => { + running += t.amount; + result.set(t.id, running); + }); + return result; + })(); + + // ── Spending insights ──────────────────────────────────────────────────────── + + const spendingInsights = (() => { + const now = new Date(); + const insights: string[] = []; + + for (let i = 0; i < 2; i++) { + const mo = new Date(now.getFullYear(), now.getMonth() - i, 1); + const me = new Date(now.getFullYear(), now.getMonth() - i + 1, 0); + const spent = transactions + .filter(t => { + const d = t.date instanceof Date ? t.date : new Date(t.date); + return t.amount < 0 && d >= mo && d <= me; + }) + .reduce((s, t) => s + Math.abs(t.amount), 0); + if (i === 0 && spent > 0) insights.push(`This month you've spent $${spent.toFixed(2)} so far.`); + if (i === 1 && spent > 0) { + const thisMo = transactions + .filter(t => { + const d = t.date instanceof Date ? t.date : new Date(t.date); + const m0 = new Date(now.getFullYear(), now.getMonth(), 1); + const m1 = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return t.amount < 0 && d >= m0 && d <= m1; + }) + .reduce((s, t) => s + Math.abs(t.amount), 0); + const diff = thisMo - spent; + if (Math.abs(diff) > 1) { + insights.push(diff > 0 + ? `You're spending $${diff.toFixed(2)} more than last month.` + : `You're spending $${Math.abs(diff).toFixed(2)} less than last month. Great job!`); + } + } + } + + const overspent = envelopes.filter(e => e.allocated > 0 && e.spent > e.allocated); + if (overspent.length > 0) { + insights.push(`${overspent.length} envelope${overspent.length > 1 ? 's are' : ' is'} overspent: ${overspent.map(e => e.name).join(', ')}.`); + } + + const nearLimit = envelopes.filter(e => e.allocated > 0 && e.spent / e.allocated >= 0.8 && e.spent <= e.allocated); + if (nearLimit.length > 0) { + insights.push(`${nearLimit.map(e => e.name).join(', ')} ${nearLimit.length > 1 ? 'are' : 'is'} near the budget limit (≥80%).`); + } + + return insights; + })(); + + // ── Render ─────────────────────────────────────────────────────────────────── + + const navBtn = (view: ViewMode, label: string) => ( + + ); const renderLanding = () => (
- Capsule Logo + Capsule Capsule

@@ -441,180 +479,481 @@ export default function BudgetDashboard() { Create envelopes, track spending, and auto-allocate income across accounts. Get set up in minutes with our guided wizard.

- -
-
- + + Auto-save & offline-ready data -
-
- + + + Guided setup wizard -
+

-
- Capsule Logo -

- Capsule -

+
+ Capsule +

Capsule

Smart Envelope Budgeting

-
-

Total Balance

-

$0.00

-

Link accounts to see your starting balance

-
-
-

Envelopes

-

Organize spending

-

Create categories with color-coded envelopes

-
-
-

Income Auto-Allocation

-

Smart distribution

-

Allocate paychecks by percentage or fixed amounts

-
+ {[ + { label: 'Total Balance', body: '$0.00', sub: 'Link accounts to see your starting balance', c: 'var(--color-primary-blue)', bg: 'rgba(30,115,190,0.05)', border: 'var(--color-cloud-blue)' }, + { label: 'Envelopes', body: 'Organize spending', sub: 'Create categories with color-coded envelopes', c: 'var(--color-finance-green)', bg: 'rgba(40,167,69,0.05)', border: 'var(--color-finance-green)' }, + { label: 'Income Auto-Allocation', body: 'Smart distribution', sub: 'Allocate paychecks by percentage or fixed amounts', c: 'var(--color-primary-blue)', bg: 'rgba(167,216,248,0.1)', border: 'var(--color-cloud-blue)' }, + ].map(({ label, body, sub, c, bg, border }) => ( +
+

{label}

+

{body}

+

{sub}

+
+ ))}
); + const renderDashboard = () => { + const assetTypes: Account['type'][] = ['checking', 'savings', 'investment']; + const debtTypes: Account['type'][] = ['credit_card', 'mortgage', 'loan']; + const totalAssets = accounts.filter(a => assetTypes.includes(a.type)).reduce((s, a) => s + a.balance, 0); + const totalDebt = Math.abs(accounts.filter(a => debtTypes.includes(a.type)).reduce((s, a) => s + a.balance, 0)); + const totalBalance = accounts.reduce((s, a) => s + a.balance, 0); + const now = new Date(); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const monthTxns = transactions.filter(t => { + const d = t.date instanceof Date ? t.date : new Date(t.date); + return d >= monthStart && d <= monthEnd; + }); + const totalBudget = envelopes.reduce((s, e) => s + e.allocated, 0); + const totalSpent = monthTxns.filter(t => t.amount < 0).reduce((s, t) => s + Math.abs(t.amount), 0); + const budgetPct = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0; + + return dashboardSections.filter(s => s.visible).map(section => { + switch (section.id) { + case 'summaryCards': + return ( +
+
+ {[ + { label: 'Total Assets', value: `$${totalAssets.toFixed(2)}`, color: 'bg-green-500', textColor: 'text-green-600', icon: '+', view: 'accounts' as ViewMode }, + { label: 'Total Debt', value: `$${totalDebt.toFixed(2)}`, color: 'bg-red-500', textColor: 'text-red-600', icon: '−', view: 'accounts' as ViewMode }, + { label: 'Net Worth', value: `$${totalBalance.toFixed(2)}`, color: 'bg-blue-500', textColor: totalBalance >= 0 ? 'text-blue-600' : 'text-red-600', icon: '$', view: 'accounts' as ViewMode }, + { label: 'Envelopes', value: envelopes.length.toString(), color: 'bg-purple-500', textColor: 'text-purple-600', icon: 'E', view: 'envelopes' as ViewMode }, + { label: 'Transactions', value: transactions.length.toString(), color: 'bg-orange-500', textColor: 'text-gray-900', icon: 'T', view: 'transactions' as ViewMode }, + ].map(({ label, value, color, textColor, icon, view }) => ( + + ))} +
+
+ ); + + case 'charts': + return ( +
+ +
+ ); + + case 'insights': + return spendingInsights.length > 0 ? ( +
+
+

Spending Insights

+
    + {spendingInsights.map((insight, i) => ( +
  • + • + {insight} +
  • + ))} +
+
+
+ ) : null; + + case 'budgetProgress': + return ( +
+
+

Budget Progress This Month

+
+ Total Spending + ${totalSpent.toFixed(2)} / ${totalBudget.toFixed(2)} +
+
+
= 100 ? 'bg-red-500' : budgetPct >= 80 ? 'bg-yellow-500' : 'bg-green-500'}`} + style={{ width: `${Math.min(budgetPct, 100)}%` }} /> +
+

{budgetPct}% of monthly budget used

+ {/* Per-envelope progress */} +
+ {envelopes.filter(e => e.allocated > 0).slice(0, 5).map(e => { + const pct = Math.round((e.spent / e.allocated) * 100); + return ( +
+
+ + + {e.name} + {e.rollover && ↻} + + ${e.spent.toFixed(0)} / ${e.allocated.toFixed(0)} +
+
+
= 100 ? 'bg-red-500' : pct >= 80 ? 'bg-yellow-400' : 'bg-green-400'}`} + style={{ width: `${Math.min(pct, 100)}%`, backgroundColor: pct < 80 ? e.color : undefined }} /> +
+
+ ); + })} +
+
+
+ ); + + case 'goals': + return goals.length > 0 ? ( +
+
+
+

Goals

+ +
+
+ {goals.map(g => { + const pct = g.targetAmount > 0 ? Math.min(100, Math.round((g.currentAmount / g.targetAmount) * 100)) : 0; + return ( +
+
+ + {g.name} + {pct}% +
+
+
+
+
+ ${g.currentAmount.toFixed(2)} + ${g.targetAmount.toFixed(2)} +
+ {g.targetDate && ( +

Target: {new Date(g.targetDate).toLocaleDateString()}

+ )} +
+ ); + })} +
+
+
+ ) : null; + + case 'quickActions': + return ( +
+
+

Quick Actions

+
+ {[ + { icon: '➕', label: 'Add Transaction', view: 'transactions' as ViewMode, bg: 'bg-blue-50 border-blue-200 hover:bg-blue-100' }, + { icon: '📦', label: 'Envelopes', view: 'envelopes' as ViewMode, bg: 'bg-purple-50 border-purple-200 hover:bg-purple-100' }, + { icon: '🏦', label: 'Accounts', view: 'accounts' as ViewMode, bg: 'bg-green-50 border-green-200 hover:bg-green-100' }, + { icon: '🎯', label: 'Goals', view: 'goals' as ViewMode, bg: 'bg-yellow-50 border-yellow-200 hover:bg-yellow-100' }, + ].map(({ icon, label, view, bg }) => ( + + ))} +
+
+
+ ); + + case 'recentTransactions': + return ( +
+
+
+

Recent Transactions

+ +
+
    + {transactions.slice().sort((a, b) => { + const da = a.date instanceof Date ? a.date : new Date(a.date); + const db = b.date instanceof Date ? b.date : new Date(b.date); + return db.getTime() - da.getTime(); + }).slice(0, 5).map(t => { + const env = envelopes.find(e => e.id === t.envelopeId); + return ( +
  • +
    + {env && } +
    +

    {t.description}

    +

    + {(t.date instanceof Date ? t.date : new Date(t.date)).toLocaleDateString()} + {env && ` · ${env.name}`} +

    +
    +
    + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {t.amount >= 0 ? '+' : '-'}${Math.abs(t.amount).toFixed(2)} + +
  • + ); + })} +
+
+
+ ); + + default: + return null; + } + }); + }; + + const renderTransactions = () => ( +
+

Transactions

+ ({ id: a.id, name: a.name }))} + transactions={transactions} + /> + + {/* Filter bar */} +
+ setTxSearch(e.target.value)} + className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+ setTxDateFrom(e.target.value)} + className="flex-1 px-2 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> + setTxDateTo(e.target.value)} + className="flex-1 px-2 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> +
+
+ {(txSearch || txAccountFilter || txEnvelopeFilter || txDateFrom || txDateTo) && ( + + )} + + {/* Transaction list */} +
+
+

+ All Transactions {filteredTransactions.length !== transactions.length && `(${filteredTransactions.length} of ${transactions.length})`} +

+
+ {filteredTransactions.length === 0 ? ( +

No transactions match your filters.

+ ) : ( +
+ + + + + + + + + + + + + {filteredTransactions.map(t => { + const env = envelopes.find(e => e.id === t.envelopeId); + const acc = accounts.find(a => a.id === t.accountId); + const runBal = runningBalances.get(t.id) ?? 0; + return ( + + + + + + + + + + ); + })} + +
DateDescriptionEnvelopeAccountAmountBalance +
+ {(t.date instanceof Date ? t.date : new Date(t.date)).toLocaleDateString()} + + {t.description} + {t.isRecurring && ↻} + + {env ? ( + + + {env.name} + + ) : —} + {acc?.name ?? '—'}= 0 ? 'text-green-600' : 'text-red-600'}`}> + {t.amount >= 0 ? '+' : '-'}${Math.abs(t.amount).toFixed(2)} + + + +
+
+ )} +
+
+ ); + + const renderEnvelopes = () => ( +
+

Envelopes

+
+ {envelopes.map(env => { + const incomeAlloc = transactions.filter(t => t.envelopeId === env.id && t.amount > 0).reduce((s, t) => s + t.amount, 0); + const totalAlloc = env.allocated + incomeAlloc; + const remaining = totalAlloc - env.spent; + const pct = totalAlloc > 0 ? Math.min(100, Math.round((env.spent / totalAlloc) * 100)) : 0; + return ( +
+
+
+
+
+ +

{env.name}

+ {env.rollover && ↻} +
+ +
+
+
= 100 ? 'bg-red-500' : pct >= 80 ? 'bg-yellow-400' : ''}`} + style={{ width: `${pct}%`, backgroundColor: pct < 80 ? env.color : undefined }} /> +
+
+
Allocated${totalAlloc.toFixed(2)}
+
Spent${env.spent.toFixed(2)}
+
+ Remaining${remaining.toFixed(2)} +
+
+
+
+ ); + })} +
+
+ +
+
+ ); + return (
{/* Navigation */}