Files
budget-app/src/pages/Buckets.tsx
Claude 37bc35f53f feat: initial BudgetWise budgeting app with Appwrite sync
Full-stack PWA budgeting app installable on Android via Chrome.

Features implemented:
- Monthly balance sheet: income sources (monthly/yearly), buffer (fixed $ or % of income)
- Buckets: regular, savings-goal, and investment types with custom goals
  (amount or % of income, monthly or yearly schedule)
- Debt tracker: manual or auto-EMI (amortization formula), interest rate,
  remaining balance tracking, avalanche-method repayment suggestions
- Loan calculator: max affordable principal and EMI checker based on
  remaining income after all expenses
- Investment projections: compound interest at monthly/yearly rate,
  1/5/10-year projections per bucket
- Deposit / withdraw transactions per bucket with balance history
- Appwrite self-hosted backend: auth, 5 collections (balance_sheets,
  incomes, buckets, debts, transactions)
- scripts/setup-appwrite.cjs: one-command DB setup via node-appwrite
- PWA manifest + service worker (vite-plugin-pwa) for Android install
- Dark mobile-first UI with TailwindCSS, Zustand state, React Router v6

https://claude.ai/code/session_01Ny2EMaZYvzk5SSVDAPgxpP
2026-02-22 14:10:44 +00:00

390 lines
13 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { Plus, Wallet, TrendingUp, PiggyBank } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useStore } from '../store';
import { getBuckets, createBucket, updateBucket, deleteBucket } from '../appwrite/db';
import { bucketMonthlyAllocation } from '../lib/calculations';
import { formatCurrency, randomBucketColor, BUCKET_COLORS } from '../lib/utils';
import { Layout } from '../components/layout/Layout';
import { Header } from '../components/layout/Header';
import { Card, SectionHeader } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Input, Select } from '../components/ui/Input';
import { Modal, ConfirmModal } from '../components/ui/Modal';
import { Badge, ProgressBar } from '../components/ui/Badge';
import type { Bucket, BucketType } from '../types';
const TYPE_ICONS = {
regular: Wallet,
savings: PiggyBank,
investment: TrendingUp,
};
const TYPE_LABELS: Record<BucketType, string> = {
regular: 'Regular',
savings: 'Savings',
investment: 'Investment',
};
function BucketForm({
initial,
onSave,
onCancel,
}: {
initial?: Partial<Bucket>;
onSave: (data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [type, setType] = useState<BucketType>(initial?.type ?? 'regular');
const [color, setColor] = useState(initial?.color ?? randomBucketColor());
const [goalAmount, setGoalAmount] = useState(String(initial?.goal_amount ?? ''));
const [goalType, setGoalType] = useState<'amount' | 'percent'>(initial?.goal_type ?? 'amount');
const [goalFreq, setGoalFreq] = useState<'monthly' | 'yearly'>(initial?.goal_frequency ?? 'monthly');
const [returnPct, setReturnPct] = useState(String(initial?.return_percent ?? ''));
const [returnFreq, setReturnFreq] = useState<'monthly' | 'yearly'>(initial?.return_frequency ?? 'yearly');
const { user } = useStore();
function submit() {
onSave({
name,
description,
type,
color,
goal_amount: parseFloat(goalAmount) || 0,
goal_type: goalType,
goal_frequency: goalFreq,
return_percent: parseFloat(returnPct) || 0,
return_frequency: returnFreq,
current_balance: initial?.current_balance ?? 0,
sort_order: initial?.sort_order ?? 0,
user_id: user?.$id ?? '',
});
}
return (
<div className="flex flex-col gap-4">
<Input
label="Bucket name"
placeholder="Emergency Fund, Vacation..."
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Input
label="Description (optional)"
placeholder="What's this bucket for?"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<Select label="Type" value={type} onChange={(e) => setType(e.target.value as BucketType)}>
<option value="regular">Regular</option>
<option value="savings">Savings Goal</option>
<option value="investment">Investment</option>
</Select>
{/* Color picker */}
<div>
<label className="text-sm font-medium text-slate-300 mb-2 block">Color</label>
<div className="flex flex-wrap gap-2">
{BUCKET_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className="w-7 h-7 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: c,
outline: color === c ? `2px solid ${c}` : 'none',
outlineOffset: '2px',
}}
/>
))}
</div>
</div>
{/* Goal settings */}
<div className="border-t border-slate-700 pt-4">
<p className="text-sm font-medium text-slate-300 mb-3">Goal / Allocation</p>
<div className="flex gap-2 mb-3">
{(['amount', 'percent'] as const).map((t) => (
<button
key={t}
onClick={() => setGoalType(t)}
className={`flex-1 py-1.5 text-xs rounded-lg transition-colors font-medium ${
goalType === t ? 'bg-accent text-white' : 'bg-surface-raised text-slate-400'
}`}
>
{t === 'amount' ? 'Fixed $' : '% of Income'}
</button>
))}
</div>
<div className="flex gap-2">
<Input
label={goalType === 'amount' ? 'Goal amount' : 'Percent of income'}
type="number"
placeholder="0"
value={goalAmount}
onChange={(e) => setGoalAmount(e.target.value)}
prefix={goalType === 'amount' ? '$' : undefined}
suffix={goalType === 'percent' ? '%' : undefined}
inputMode="decimal"
/>
<Select
label="Frequency"
value={goalFreq}
onChange={(e) => setGoalFreq(e.target.value as 'monthly' | 'yearly')}
>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</Select>
</div>
</div>
{/* Investment settings */}
{type === 'investment' && (
<div className="border-t border-slate-700 pt-4">
<p className="text-sm font-medium text-slate-300 mb-3">Returns</p>
<div className="flex gap-2">
<Input
label="Return %"
type="number"
placeholder="8"
value={returnPct}
onChange={(e) => setReturnPct(e.target.value)}
suffix="%"
inputMode="decimal"
/>
<Select
label="Period"
value={returnFreq}
onChange={(e) => setReturnFreq(e.target.value as 'monthly' | 'yearly')}
>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</Select>
</div>
</div>
)}
<div className="flex gap-3 mt-2">
<Button variant="secondary" fullWidth onClick={onCancel}>
Cancel
</Button>
<Button fullWidth onClick={submit} disabled={!name}>
Save Bucket
</Button>
</div>
</div>
);
}
export function Buckets() {
const navigate = useNavigate();
const { user, buckets, setBuckets, addBucket, updateBucketItem, removeBucket } = useStore();
const totalMonthlyIncome = useStore((s) =>
s.incomes.reduce(
(sum, i) => sum + (i.frequency === 'yearly' ? i.amount / 12 : i.amount),
0,
),
);
const [showModal, setShowModal] = useState(false);
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
const [deletingBucket, setDeletingBucket] = useState<Bucket | null>(null);
const [loading, setLoading] = useState(false);
const loadBuckets = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const bkts = await getBuckets(user.$id);
setBuckets(bkts);
} finally {
setLoading(false);
}
}, [user, setBuckets]);
useEffect(() => {
loadBuckets();
}, [loadBuckets]);
async function handleCreate(data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) {
const b = await createBucket({ ...data, sort_order: buckets.length });
addBucket(b);
setShowModal(false);
}
async function handleUpdate(data: Omit<Bucket, '$id' | '$createdAt' | '$updatedAt'>) {
if (!editingBucket) return;
const b = await updateBucket(editingBucket.$id, data);
updateBucketItem(editingBucket.$id, b);
setEditingBucket(null);
}
async function handleDelete() {
if (!deletingBucket) return;
await deleteBucket(deletingBucket.$id);
removeBucket(deletingBucket.$id);
setDeletingBucket(null);
}
const totalAllocation = buckets.reduce(
(sum, b) => sum + bucketMonthlyAllocation(b, totalMonthlyIncome),
0,
);
const groupedBuckets = {
regular: buckets.filter((b) => b.type === 'regular'),
savings: buckets.filter((b) => b.type === 'savings'),
investment: buckets.filter((b) => b.type === 'investment'),
};
function BucketList({ type }: { type: BucketType }) {
const list = groupedBuckets[type];
if (list.length === 0) return null;
const Icon = TYPE_ICONS[type];
return (
<div>
<SectionHeader title={TYPE_LABELS[type]} />
<div className="space-y-2">
{list.map((b) => {
const allocation = bucketMonthlyAllocation(b, totalMonthlyIncome);
const progress =
b.goal_amount > 0 ? (b.current_balance / b.goal_amount) * 100 : 0;
return (
<Card key={b.$id} onClick={() => navigate(`/buckets/${b.$id}`)}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: b.color + '33', border: `1px solid ${b.color}55` }}
>
<Icon size={16} style={{ color: b.color }} />
</div>
<div>
<p className="text-sm font-medium text-white">{b.name}</p>
{b.description && (
<p className="text-xs text-slate-500 truncate max-w-[160px]">{b.description}</p>
)}
</div>
</div>
<div className="text-right">
<p className="text-sm font-mono font-semibold text-white">
{formatCurrency(b.current_balance)}
</p>
{allocation > 0 && (
<p className="text-xs text-slate-500">+{formatCurrency(allocation)}/mo</p>
)}
</div>
</div>
{b.goal_amount > 0 && (
<ProgressBar value={progress} color={b.color} showPercent />
)}
{type === 'investment' && b.return_percent > 0 && (
<p className="text-xs text-success mt-1">
{b.return_percent}% / {b.return_frequency}
</p>
)}
<div className="flex gap-2 mt-2">
<button
onClick={(e) => {
e.stopPropagation();
setEditingBucket(b);
}}
className="text-xs text-slate-400 hover:text-white transition-colors px-2"
>
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeletingBucket(b);
}}
className="text-xs text-slate-500 hover:text-danger transition-colors px-2"
>
Delete
</button>
</div>
</Card>
);
})}
</div>
</div>
);
}
return (
<Layout>
<Header
title="Buckets"
action={
<Button size="sm" onClick={() => setShowModal(true)}>
<Plus size={14} /> New
</Button>
}
/>
<div className="px-4 py-4 space-y-5">
{totalAllocation > 0 && (
<div className="bg-surface rounded-2xl px-4 py-3 flex justify-between items-center">
<span className="text-sm text-slate-400">Total monthly allocation</span>
<span className="text-sm font-semibold font-mono text-white">
{formatCurrency(totalAllocation)}
</span>
</div>
)}
{loading ? (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
) : buckets.length === 0 ? (
<Card>
<div className="text-center py-6">
<Wallet size={32} className="text-slate-600 mx-auto mb-3" />
<p className="text-slate-400 mb-2">No buckets yet.</p>
<p className="text-xs text-slate-500">
Create buckets for savings goals, investments, or expense categories.
</p>
</div>
</Card>
) : (
<>
<BucketList type="regular" />
<BucketList type="savings" />
<BucketList type="investment" />
</>
)}
</div>
<Modal open={showModal} onClose={() => setShowModal(false)} title="New Bucket" size="lg">
<BucketForm onSave={handleCreate} onCancel={() => setShowModal(false)} />
</Modal>
<Modal
open={!!editingBucket}
onClose={() => setEditingBucket(null)}
title="Edit Bucket"
size="lg"
>
<BucketForm
initial={editingBucket ?? undefined}
onSave={handleUpdate}
onCancel={() => setEditingBucket(null)}
/>
</Modal>
<ConfirmModal
open={!!deletingBucket}
onClose={() => setDeletingBucket(null)}
onConfirm={handleDelete}
title="Delete bucket?"
message={`"${deletingBucket?.name}" and all its transactions will be deleted. This cannot be undone.`}
confirmLabel="Delete"
danger
/>
</Layout>
);
}