Category Cards and Derived State
The category grid is the heart of ZeroBudget. Each card shows budgeted, spent, and remaining amounts — all derived from state — plus a progress bar that turns yellow at 75% and red at 100%.
Derived values in useBudget
Section titled “Derived values in useBudget”Add these computed values to useBudget before building the UI:
const totalIncome = useMemo( () => incomeSources.reduce((sum, s) => sum + Number(s.amount || 0), 0), [incomeSources]);
const totalBudgeted = useMemo( () => categories.reduce((sum, c) => sum + Number(c.budgeted || 0), 0), [categories]);
const leftToAssign = totalIncome - totalBudgeted;
const spentByCategory = useMemo(() => { const map = {}; for (const t of transactions) { map[t.categoryId] = (map[t.categoryId] || 0) + Number(t.amount || 0); } return map;}, [transactions]);Category actions in useBudget
Section titled “Category actions in useBudget”const updateCategory = useCallback((id, updates) => { setMonthData(d => ({ ...d, categories: d.categories.map(c => c.id === id ? { ...c, ...updates } : c), }));}, [setMonthData]);
const addCategory = useCallback((name) => { setMonthData(d => ({ ...d, categories: [...d.categories, { id: crypto.randomUUID(), name, budgeted: 0 }], }));}, [setMonthData]);
const deleteCategory = useCallback((id) => { setMonthData(d => ({ ...d, categories: d.categories.filter(c => c.id !== id), transactions: d.transactions.filter(t => t.categoryId !== id), }));}, [setMonthData]);Deleting a category also deletes its transactions — both arrays are updated in one setMonthData call.
Building CategoryCard
Section titled “Building CategoryCard”import { useState } from 'react';import { useBudgetContext } from '../../context/BudgetContext';
export default function CategoryCard({ category }) { const { spentByCategory, updateCategory, deleteCategory } = useBudgetContext(); const [editingBudget, setEditingBudget] = useState(false); const [budgetVal, setBudgetVal] = useState(category.budgeted);
const spent = spentByCategory[category.id] || 0; const budgeted = Number(category.budgeted || 0); const remaining = budgeted - spent; const pct = budgeted > 0 ? Math.min((spent / budgeted) * 100, 100) : 0; const barClass = pct >= 100 ? 'bar--over' : pct >= 75 ? 'bar--warning' : 'bar--ok';
function saveBudget() { updateCategory(category.id, { budgeted: parseFloat(budgetVal) || 0 }); setEditingBudget(false); }
return ( <div className="category-card card"> <div className="category-card__header"> <h3>{category.name}</h3> <button onClick={() => deleteCategory(category.id)} className="btn-icon btn-icon--danger">×</button> </div>
<div className="category-card__amounts"> <div> <span className="stat-label">Budgeted</span> {editingBudget ? ( <span> <input type="number" value={budgetVal} onChange={e => setBudgetVal(e.target.value)} onKeyDown={e => e.key === 'Enter' && saveBudget()} autoFocus /> <button onClick={saveBudget}>✓</button> <button onClick={() => setEditingBudget(false)}>✕</button> </span> ) : ( <span className="stat-value clickable" onClick={() => setEditingBudget(true)}> {budgeted.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} </span> )} </div> <div> <span className="stat-label">Spent</span> <span className="stat-value">{spent.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</span> </div> <div> <span className="stat-label">Remaining</span> <span className={`stat-value ${remaining < 0 ? 'text-danger' : ''}`}> {remaining.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} </span> </div> </div>
<div className="progress-bar"> <div className={`progress-bar__fill ${barClass}`} style={{ width: `${pct}%` }} /> </div> </div> );}LeftToAssign
Section titled “LeftToAssign”export default function LeftToAssign() { const { leftToAssign } = useBudgetContext(); const isNegative = leftToAssign < 0; const isZero = leftToAssign === 0; const fmt = n => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
return ( <div className={`left-to-assign ${isNegative ? 'left-to-assign--over' : isZero ? 'left-to-assign--zero' : ''}`}> <span className="lta-label">Left to Assign</span> <span className="lta-amount">{fmt(leftToAssign)}</span> {isNegative && <span className="lta-warning">Over-budgeted!</span>} {isZero && <span className="lta-success">Every dollar is assigned.</span>} </div> );}Exercise
Section titled “Exercise”- Add
totalIncome,totalBudgeted,leftToAssign, andspentByCategorytouseBudget. - Add
updateCategory,addCategory, anddeleteCategorywithuseCallback. - Build
CategoryCardwith inline budget editing and the progress bar. - Build
LeftToAssignreading from context. - Add the add-category form to
App.jsx. Confirm adding and deleting categories works, and that spending a category’s budget turns the progress bar yellow/red.
- All spending calculations are derived in
useBudgetwithuseMemo— never stored as separate state. spentByCategoryis a map from category ID to total spent, built from the transactions array.CategoryCardreads from context for shared data and manages local state only for the inline edit UI.- Deleting a category removes its transactions in the same
setMonthDatacall.