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
390 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|