Skip to content

Commit b47f3db

Browse files
authored
fix(expense-tracker): unify budget table layout (#84)
1 parent 7948c86 commit b47f3db

2 files changed

Lines changed: 127 additions & 32 deletions

File tree

src/web/src/ExpenseTrackerPage.tsx

Lines changed: 114 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
archiveCategory,
66
autoCopyBudgetsFromPreviousMonth,
77
Category,
8+
deleteBudget,
89
Expense,
910
getMonthActivitySummary,
1011
MonthActivityActionType,
@@ -43,11 +44,22 @@ type CategoryDropPlacement = 'before' | 'after';
4344
type BudgetSort = 'custom' | 'alpha-asc' | 'alpha-desc' | 'budget-desc' | 'spent-desc' | 'remaining-desc';
4445

4546
const DEFAULT_SUBCATEGORY_ORDER = ['Bills', 'Savings', 'Debts', 'Subscriptions', 'Variable Spending'];
47+
const budgetTableDesktopColumns = 'sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]';
4648

4749
const today = new Date();
4850
const monthString = (date: Date): string => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
4951
const dateString = (date: Date): string => `${monthString(date)}-${String(date.getDate()).padStart(2, '0')}`;
50-
const formatCurrency = (cents: number): string => (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
52+
const usdCurrencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
53+
const budgetEditFormatter = new Intl.NumberFormat('en-US', {
54+
minimumFractionDigits: 0,
55+
maximumFractionDigits: 2,
56+
useGrouping: false
57+
});
58+
const formatCurrency = (cents: number): string => usdCurrencyFormatter.format(cents / 100);
59+
const formatBudgetDisplayValue = (amountCents: number | undefined): string =>
60+
typeof amountCents === 'number' && Number.isFinite(amountCents) ? formatCurrency(amountCents) : '';
61+
const formatBudgetEditValue = (amountCents: number | undefined): string =>
62+
typeof amountCents === 'number' && Number.isFinite(amountCents) ? budgetEditFormatter.format(amountCents / 100) : '';
5163
const formatMonthLabel = (month: string): string => {
5264
const [year, monthPart] = month.split('-').map(Number);
5365
return new Date(year, monthPart - 1, 1).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
@@ -69,6 +81,28 @@ const parseCurrencyInputToCents = (value: string): number | null => {
6981
return Math.round(parsed * 100);
7082
};
7183

84+
const parseBudgetInputToCents = (value: string): { amountCents: number | null; isBlank: boolean } => {
85+
const trimmed = value.trim();
86+
if (!trimmed) {
87+
return { amountCents: null, isBlank: true };
88+
}
89+
90+
const normalized = trimmed.replace(/[$,\s]/g, '');
91+
if (!normalized) {
92+
return { amountCents: null, isBlank: true };
93+
}
94+
95+
const parsed = Number(normalized);
96+
if (!Number.isFinite(parsed) || parsed < 0) {
97+
return { amountCents: null, isBlank: false };
98+
}
99+
100+
return { amountCents: Math.round(parsed * 100), isBlank: false };
101+
};
102+
103+
const hasBudgetValue = (budgetMap: Record<string, number>, categoryId: string): boolean =>
104+
Object.prototype.hasOwnProperty.call(budgetMap, categoryId);
105+
72106
export default function ExpenseTrackerPage() {
73107
const [selectedMonth, setSelectedMonth] = useState(monthString(today));
74108
const [categories, setCategories] = useState<Category[]>([]);
@@ -182,7 +216,7 @@ export default function ExpenseTrackerPage() {
182216
);
183217
setBudgetInputs(
184218
nextCategories.reduce<Record<string, string>>((acc, category) => {
185-
acc[category.id] = budgetMap[category.id] ? String(budgetMap[category.id] / 100) : '';
219+
acc[category.id] = formatBudgetDisplayValue(budgetMap[category.id]);
186220
return acc;
187221
}, {})
188222
);
@@ -798,15 +832,43 @@ export default function ExpenseTrackerPage() {
798832
const handleBudgetAutoSave = async (categoryId: string) => {
799833
try {
800834
const rawValue = budgetInputs[categoryId] ?? '';
801-
const amount = Number(rawValue);
802-
if (!Number.isFinite(amount) || amount < 0) {
835+
const { amountCents, isBlank } = parseBudgetInputToCents(rawValue);
836+
const current = budgets[categoryId] ?? 0;
837+
const hasSavedBudget = hasBudgetValue(budgets, categoryId);
838+
839+
if (isBlank) {
840+
if (!hasSavedBudget) {
841+
setBudgetInputs(prev => ({ ...prev, [categoryId]: '' }));
842+
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'idle' }));
843+
return;
844+
}
845+
846+
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'saving' }));
847+
await deleteBudget(selectedMonth, categoryId);
848+
await recordMonthActivity('budget-updated', 'Updated budget amount', `${getCategoryName(categoryId)} • cleared`);
849+
setBudgets(prev => {
850+
const next = { ...prev };
851+
delete next[categoryId];
852+
return next;
853+
});
854+
setBudgetInputs(prev => ({ ...prev, [categoryId]: '' }));
855+
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'saved' }));
856+
if (budgetStatusTimeouts.current[categoryId]) {
857+
window.clearTimeout(budgetStatusTimeouts.current[categoryId]);
858+
}
859+
budgetStatusTimeouts.current[categoryId] = window.setTimeout(() => {
860+
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'idle' }));
861+
}, 1500);
862+
return;
863+
}
864+
865+
if (amountCents === null) {
803866
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'error' }));
804867
return;
805868
}
806869

807-
const amountCents = Math.round(amount * 100);
808-
const current = budgets[categoryId] ?? 0;
809-
if (amountCents === current) {
870+
if (amountCents === current && hasSavedBudget) {
871+
setBudgetInputs(prev => ({ ...prev, [categoryId]: formatBudgetDisplayValue(amountCents) }));
810872
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'idle' }));
811873
return;
812874
}
@@ -815,6 +877,7 @@ export default function ExpenseTrackerPage() {
815877
await upsertBudget(selectedMonth, categoryId, amountCents);
816878
await recordMonthActivity('budget-updated', 'Updated budget amount', `${getCategoryName(categoryId)}${formatCurrency(amountCents)}`);
817879
setBudgets(prev => ({ ...prev, [categoryId]: amountCents }));
880+
setBudgetInputs(prev => ({ ...prev, [categoryId]: formatBudgetDisplayValue(amountCents) }));
818881
setBudgetSaveStatus(prev => ({ ...prev, [categoryId]: 'saved' }));
819882
if (budgetStatusTimeouts.current[categoryId]) {
820883
window.clearTimeout(budgetStatusTimeouts.current[categoryId]);
@@ -827,6 +890,25 @@ export default function ExpenseTrackerPage() {
827890
}
828891
};
829892

893+
const handleBudgetInputFocus = (categoryId: string) => {
894+
const savedDisplayValue = hasBudgetValue(budgets, categoryId) ? formatBudgetDisplayValue(budgets[categoryId]) : '';
895+
const currentValue = budgetInputs[categoryId] ?? '';
896+
897+
if (currentValue && currentValue !== savedDisplayValue) {
898+
return;
899+
}
900+
901+
setBudgetInputs(prev => ({
902+
...prev,
903+
[categoryId]: hasBudgetValue(budgets, categoryId) ? formatBudgetEditValue(budgets[categoryId]) : ''
904+
}));
905+
};
906+
907+
const handleBudgetInputChange = (categoryId: string, value: string) => {
908+
setBudgetInputs(prev => ({ ...prev, [categoryId]: value }));
909+
setBudgetSaveStatus(prev => (prev[categoryId] ? { ...prev, [categoryId]: 'idle' } : prev));
910+
};
911+
830912
const handleDelete = async (expense: Expense) => {
831913
try {
832914
await deleteExpense(expense.id);
@@ -1242,12 +1324,12 @@ export default function ExpenseTrackerPage() {
12421324
</div>
12431325
{!isBudgetsCollapsed && (
12441326
<div className="rounded border border-slate-800">
1245-
<div className="hidden items-center gap-x-3 border-b border-slate-800 px-3 py-2 text-xs uppercase tracking-wide text-slate-400 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]">
1327+
<div className={`hidden items-center gap-x-3 border-b border-slate-800 px-3 py-2 text-xs uppercase tracking-wide text-slate-400 sm:grid ${budgetTableDesktopColumns}`}>
12461328
<div>Item</div>
1247-
<div className="justify-self-center text-center">Budget</div>
1248-
<div className="justify-self-center text-center">Spent</div>
1249-
<div className="justify-self-center text-center">Remaining</div>
1250-
<div className="justify-self-center text-center">Status</div>
1329+
<div className="text-center">Budget</div>
1330+
<div className="text-center">Spent</div>
1331+
<div className="text-center">Remaining</div>
1332+
<div className="text-center">Status</div>
12511333
</div>
12521334
<div className="divide-y divide-slate-800">
12531335
{groupedBudgetItems.map(group => {
@@ -1310,7 +1392,7 @@ export default function ExpenseTrackerPage() {
13101392
return (
13111393
<div
13121394
key={category.id}
1313-
className={`grid grid-cols-2 gap-x-3 gap-y-1 px-3 py-2 text-sm sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)] ${
1395+
className={`grid grid-cols-2 gap-x-3 gap-y-1 px-3 py-2 text-sm sm:grid ${budgetTableDesktopColumns} ${
13141396
budgetSort === 'custom' ? 'cursor-move' : ''
13151397
} ${isDragging ? 'opacity-60' : ''} ${dragIndicator}`}
13161398
draggable={budgetSort === 'custom'}
@@ -1320,35 +1402,35 @@ export default function ExpenseTrackerPage() {
13201402
onDragEnd={handleBudgetDragEnd}
13211403
>
13221404
<div className="order-1 font-medium sm:order-none">{category.name}</div>
1323-
<div className="order-4 justify-self-stretch sm:order-none sm:w-full sm:max-w-[12rem] sm:justify-self-center">
1405+
<div className="order-4 justify-self-stretch sm:order-none sm:flex sm:w-full sm:items-center sm:justify-center">
13241406
<input
1325-
type="number"
1326-
min="0"
1327-
step="0.01"
1407+
type="text"
13281408
inputMode="decimal"
1329-
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-center text-sm tabular-nums"
1409+
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-center text-sm tabular-nums sm:max-w-[12rem]"
13301410
value={budgetInputs[category.id] ?? ''}
1331-
onChange={e => setBudgetInputs(prev => ({ ...prev, [category.id]: e.target.value }))}
1411+
onFocus={() => handleBudgetInputFocus(category.id)}
1412+
onChange={e => handleBudgetInputChange(category.id, e.target.value)}
13321413
onBlur={() => void handleBudgetAutoSave(category.id)}
13331414
onKeyDown={event => {
13341415
if (event.key === 'Enter') {
13351416
event.preventDefault();
13361417
event.currentTarget.blur();
13371418
}
13381419
}}
1420+
placeholder="$0.00"
13391421
aria-label={`Budget for ${category.name}`}
13401422
/>
13411423
</div>
1342-
<div className="order-3 justify-self-end text-right tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center">{formatCurrency(spent)}</div>
1424+
<div className="order-3 justify-self-end text-right tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center">{formatCurrency(spent)}</div>
13431425
<div
1344-
className={`order-2 justify-self-end text-right font-semibold tabular-nums sm:order-none sm:justify-self-center sm:text-center ${
1426+
className={`order-2 justify-self-end text-right font-semibold tabular-nums sm:order-none sm:w-full sm:text-center ${
13451427
delta >= 0 ? 'text-emerald-300' : 'text-rose-300'
13461428
}`}
13471429
>
13481430
{formatCurrency(delta)}
13491431
</div>
13501432
<div
1351-
className={`order-5 col-span-2 justify-self-end text-right text-xs sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center ${
1433+
className={`order-5 col-span-2 justify-self-end text-right text-xs sm:col-span-1 sm:order-none sm:w-full sm:text-center ${
13521434
status === 'error' ? 'text-rose-300' : status === 'saved' ? 'text-emerald-300' : 'text-slate-400'
13531435
}`}
13541436
>
@@ -1358,34 +1440,34 @@ export default function ExpenseTrackerPage() {
13581440
);
13591441
})
13601442
))}
1361-
<div className="grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/60 px-3 py-2 text-xs sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]">
1443+
<div className={`grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/60 px-3 py-2 text-xs sm:grid ${budgetTableDesktopColumns}`}>
13621444
<div className="order-1 font-semibold text-slate-300 sm:order-none">Subtotal</div>
1363-
<div className="order-4 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center">
1445+
<div className="order-4 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center">
13641446
{formatCurrency(group.totals.budget)}
13651447
</div>
1366-
<div className="order-3 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center">
1448+
<div className="order-3 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center">
13671449
{formatCurrency(group.totals.spent)}
13681450
</div>
1369-
<div className="order-2 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:justify-self-center sm:text-center">
1451+
<div className="order-2 justify-self-end text-right font-semibold tabular-nums text-slate-300 sm:order-none sm:w-full sm:text-center">
13701452
{formatCurrency(group.totals.remaining)}
13711453
</div>
1372-
<div className="order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center">
1454+
<div className="order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:w-full sm:text-center">
13731455
{group.name}
13741456
</div>
13751457
</div>
13761458
</Fragment>
13771459
);
13781460
})}
1379-
<div className="grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/40 px-3 py-2 text-sm sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,14ch)]">
1461+
<div className={`grid grid-cols-2 gap-x-3 gap-y-1 bg-slate-950/40 px-3 py-2 text-sm sm:grid ${budgetTableDesktopColumns}`}>
13801462
<div className="order-1 font-semibold text-slate-200 sm:order-none">Total</div>
1381-
<div className="order-4 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:justify-self-center sm:text-center">
1463+
<div className="order-4 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:w-full sm:text-center">
13821464
{formatCurrency(budgetTotals.budget)}
13831465
</div>
1384-
<div className="order-3 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:justify-self-center sm:text-center">
1466+
<div className="order-3 justify-self-end text-right font-semibold tabular-nums text-slate-200 sm:order-none sm:w-full sm:text-center">
13851467
{formatCurrency(budgetTotals.spent)}
13861468
</div>
1387-
<div className="order-2 justify-self-end text-right text-slate-500 sm:order-none sm:justify-self-center sm:text-center"></div>
1388-
<div className="order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:justify-self-center sm:text-center">Totals</div>
1469+
<div className="order-2 justify-self-end text-right text-slate-500 sm:order-none sm:w-full sm:text-center"></div>
1470+
<div className="order-5 col-span-2 justify-self-end text-right text-xs text-slate-500 sm:col-span-1 sm:order-none sm:w-full sm:text-center">Totals</div>
13891471
</div>
13901472
</div>
13911473
</div>

src/web/src/expenseTracker.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,19 @@ export const upsertBudget = async (month: string, categoryId: string, amountCent
476476
return budget;
477477
});
478478

479+
export const deleteBudget = async (month: string, categoryId: string): Promise<void> =>
480+
runTransaction(['budgets'], 'readwrite', async tx => {
481+
const store = tx.objectStore('budgets');
482+
const index = store.index('month_category');
483+
const existing = (await requestAsPromise(index.get([month, categoryId]))) as Budget | undefined;
484+
485+
if (!existing) {
486+
return;
487+
}
488+
489+
store.delete(existing.id);
490+
});
491+
479492
export const addExpense = async (expense: Omit<Expense, 'id' | 'createdAt' | 'updatedAt'>): Promise<Expense> => {
480493
const now = new Date().toISOString();
481494
const nextExpense: Expense = {

0 commit comments

Comments
 (0)