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
This commit is contained in:
389
src/pages/Buckets.tsx
Normal file
389
src/pages/Buckets.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user