Skip to content

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%.

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]);
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.

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>
);
}
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>
);
}
  1. Add totalIncome, totalBudgeted, leftToAssign, and spentByCategory to useBudget.
  2. Add updateCategory, addCategory, and deleteCategory with useCallback.
  3. Build CategoryCard with inline budget editing and the progress bar.
  4. Build LeftToAssign reading from context.
  5. 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 useBudget with useMemo — never stored as separate state.
  • spentByCategory is a map from category ID to total spent, built from the transactions array.
  • CategoryCard reads from context for shared data and manages local state only for the inline edit UI.
  • Deleting a category removes its transactions in the same setMonthData call.