v0.21.1: Loading Skeletons & Async State

- Reusable Skeleton component (line, circle, card, button, input variants)
- TrackerPage: skeleton cards, rows, buckets with aria-busy attributes
- BillsPage: skeleton rows during loading
- Bug fix: double closing brace />}} on Bucket component
- Hudson security audit: 5/5 PASS
This commit is contained in:
null 2026-05-10 01:35:41 -05:00
parent ac4b4653a5
commit 314159d241
9 changed files with 216 additions and 69 deletions

View File

@ -37,6 +37,35 @@
---
### v0.21.1 — Loading Skeletons & Async State
**Status:** ✅ COMPLETED
**Date:** 2026-05-10
**Priority:** MEDIUM
| Agent | Status | Time | Notes |
|-------|--------|------|-------|
| Scarlett | ✅ COMPLETED | 1m2s | Skeleton component, TrackerPage/BillsPage skeleton loaders |
| Ripley | ✅ COMPLETED | — | Fixed `/>}}` syntax error on Bucket component |
| Bishop | ✅ COMPLETED | 1m58s | 11/11 PASS |
| Hudson | ✅ COMPLETED | 17s | 5/5 PASS |
**Files modified:** `client/components/ui/Skeleton.jsx` (new), `client/pages/TrackerPage.jsx`, `client/pages/BillsPage.jsx`
**Work Completed:**
- [x] Reusable Skeleton component (line, circle, card, button, input variants)
- [x] TrackerPage skeleton cards, rows, buckets with aria-busy
- [x] BillsPage skeleton rows during loading
- [x] Bug fix: double closing brace `/>}}` on second Bucket component
**Security Audit (Hudson):**
1. XSS via className: ✅ PASS
2. No sensitive data in skeleton: ✅ PASS
3. aria-busy correctness: ✅ PASS
4. No validation bypass: ✅ PASS
5. Skeleton presentational only: ✅ PASS
---
### v0.20.9 — Previous Month Paid on Tracker
**Status:** 🔄 IN PROGRESS
**Date:** 2026-05-10

View File

@ -3,7 +3,7 @@
**This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10
**Current Version:** v0.21.0
**Current Version:** v0.21.1
## How to Use This Document
@ -39,28 +39,6 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
### 🟡 MEDIUM
### Add loading skeletons and better async state management
**Priority:** MEDIUM
**Added:** 2026-05-08 by Scarlett
**Description:**
Many pages show only "Loading..." or no state between async API calls and data rendering. Pages like TrackerPage, AnalyticsPage, and BillsPage have inconsistent loading states.
**Rationale:**
Perceived performance. Users should see immediate visual feedback when data is loading, even if the actual data loads slowly. Skeleton loaders prevent layout shifts and set proper expectations about wait times.
**Implementation Notes:**
- Add loading skeleton components for:
- Summary cards (4 skeleton cards for TrackerPage)
- Table rows (skeleton rows for bills tracker tables)
- Chart placeholders (shimmer effect for analytics)
- Form fields (skeleton inputs for modals)
- Create reusable Skeleton components in `client/components/ui/Skeleton.jsx`
- Implement loading state with proper transitions (fade in/out)
- Consider adding `aria-busy` attributes during load
- Files likely to be modified: `client/components/ui/`, `client/pages/TrackerPage.jsx`, `client/pages/AnalyticsPage.jsx`, `client/pages/BillsPage.jsx`
- Estimated effort: 60-90 minutes
### Add React Query (TanStack Query) for server state management
**Priority:** MEDIUM
**Added:** 2026-05-08 by Scarlett

View File

@ -1,5 +1,11 @@
# Bill Tracker — Changelog
## v0.21.1
### Added
- **Loading Skeletons** — Tracker and Bills pages show skeleton placeholders during data loading with `aria-busy` attributes
- Reusable `Skeleton` component with line, circle, card, button, input variants
## v0.21.0
### Added

View File

@ -0,0 +1,27 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Skeleton = React.forwardRef(({ className, variant = 'line', ...props }, ref) => {
const variants = {
line: 'h-4 w-full rounded-md',
circle: 'h-10 w-10 rounded-full',
card: 'h-24 w-full rounded-xl',
button: 'h-9 w-24 rounded-md',
input: 'h-9 w-full rounded-md',
};
return (
<div
ref={ref}
className={cn(
'animate-pulse bg-muted',
variants[variant],
className
)}
{...props}
/>
);
});
Skeleton.displayName = 'Skeleton';
export { Skeleton };

View File

@ -1,10 +1,10 @@
export const APP_VERSION = '0.21.0';
export const APP_VERSION = '0.21.1';
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: '0.21.0',
version: '0.21.1',
date: '2026-05-10',
highlights: [
{ icon: '📈', title: '3-Month Trend Indicator', desc: 'Tracker shows up/down trend vs 3-month average with percentage change.' },
{ icon: '💫', title: 'Loading Skeletons', desc: 'Tracker and Bills pages show skeleton placeholders during data loading.' },
],
};

View File

@ -3,6 +3,7 @@ import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils';
const RANGE_OPTIONS = [6, 12, 24, 36];

View File

@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/Skeleton';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
@ -451,8 +452,11 @@ export default function BillsPage() {
</div>
{loading ? (
<div className="py-16 text-center text-sm text-muted-foreground animate-pulse">
Loading bills
<div className="py-16 text-center text-sm text-muted-foreground">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="mx-auto mb-3 h-12 w-3/4 rounded-lg bg-muted animate-pulse" />
))}
<span className="animate-pulse">Loading bills</span>
</div>
) : active.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">

View File

@ -6,6 +6,7 @@ import BillModal from '@/components/BillModal';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/Skeleton';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
@ -1202,7 +1203,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
}
// Bucket
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter(r => !r.is_skipped);
const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0);
@ -1249,21 +1250,47 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
</span>
</div>
<div className="grid gap-3 p-3 lg:hidden">
{rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted" />
<div className="h-4 w-32 rounded-md bg-muted" />
</div>
</div>
<div className="h-5 w-20 rounded-md bg-muted" />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground mt-2">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<div className="h-3 w-20 rounded-md bg-muted mt-0.5" />
</div>
</div>
</div>
))
) : (
rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))
)}
</div>
<div className="hidden lg:block">
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
<Table className="min-w-[1120px]">
<TableHeader>
@ -1282,17 +1309,45 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="border-border/50">
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-48 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[10%] py-3"><div className="h-3 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right"><div className="h-3 w-20 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 ml-auto rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3"><div className="h-7 w-24 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[9%] py-3"><div className="h-5 w-20 rounded-md bg-muted" /></TableCell>
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
<div className="h-7 w-20 rounded-md bg-muted" />
<div className="h-7 w-7 rounded-md bg-muted" />
</div>
</TableCell>
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<div className="h-4 w-full rounded-md bg-muted" />
</TableCell>
</TableRow>
))
) : (
rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))
)}
</TableBody>
</Table>
</div>
@ -1307,17 +1362,21 @@ export default function TrackerPage() {
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
// Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.tracker(year, month);
setData(res);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
}, [year, month]);
@ -1399,19 +1458,30 @@ export default function TrackerPage() {
</div>
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
{loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
{summary.trend && <Skeleton variant="card" className="h-32" />}
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
</div>
)}
{/* ── Empty state ── */}
{rows.length === 0 && data !== null && (
@ -1427,8 +1497,40 @@ export default function TrackerPage() {
)}
{/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} />}
{loading && (
<div className="space-y-5" aria-busy="true">
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
<div className="rounded-xl border border-border overflow-hidden bg-card p-4">
<div className="flex items-center justify-between mb-4">
<div className="h-4 w-32 rounded-md bg-muted" />
<div className="h-4 w-16 rounded-md bg-muted" />
</div>
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 animate-pulse">
<div className="h-1.5 w-1.5 rounded-full bg-muted" />
<div className="h-4 w-64 rounded-md bg-muted" />
</div>
))}
</div>
</div>
</div>
)}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} loading={loading} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={load} onEditBill={handleOpenEditBill} loading={loading} />}
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && (

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.21.0",
"version": "0.21.1",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {