feat: compact tracker, S badge, side-by-side buckets (batch 0.33.8.5)

- Sub badge changed to 'S' in all 4 locations with matching border style
- Two-bucket grid at 2xl+ when both buckets have bills
- Compact mode: narrow table, hide Last Month column, shrink Notes/Actions/Due
- Bucket header shows remaining/Done labels alongside paid/total/overpaid
- Removed standalone Remaining summary card (redundant with bucket header)
- Row and Bucket accept compact=false
- Bump v0.33.8.4 -> v0.33.8.5
This commit is contained in:
null 2026-05-29 20:33:01 -05:00
parent a15436b637
commit f99cd82438
6 changed files with 60 additions and 35 deletions

View File

@ -1,5 +1,21 @@
# Bill Tracker — Changelog
## v0.33.8.5
### 🎨 Design
- **"S" badge (compact)** — Subscription badge shortened to "S" in all four locations (desktop tracker, mobile tracker, desktop bills table, mobile bills row), now with matching border style.
- **Tracker bucket side-by-side** — When both buckets (1st14th, 15th31st) have bills, they render in a 2-column grid at 2xl+ instead of stacked.
- **Compact bucket mode**`compact` prop narrows table min-width to 700px at 2xl+, hides Last Month column, shrinks Notes (23%→16%) and Actions (10%→8%) columns, Due trimmed (10%→9%).
- **Bucket header: remaining summary** — Shows "Remaining" and "Done" labels alongside paid/total/overpaid in bucket headers.
### 🛠 Internal
- Removed standalone `Remaining` summary card from the summary row (redundant with bucket header).
- `Row` and `Bucket` components accept `compact = false` prop.
---
## v0.33.8.4
### 🚀 Features

View File

@ -72,8 +72,8 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
</span>
)}
{prefs.showSubscription && !!bill.is_subscription && (
<span className="shrink-0 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-indigo-600 dark:text-indigo-300">
Sub
<span className="shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-indigo-600 dark:text-indigo-300">
S
</span>
)}
{hasHistory && (

View File

@ -71,7 +71,7 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
)}
{bill.is_subscription && (
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">Sub</span>
<span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span>
)}
</div>
</div>

View File

@ -200,7 +200,7 @@ export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
Sub
S
</span>
)}
</div>

View File

@ -789,7 +789,7 @@ function NotesCell({ row, refresh }) {
}
// Table row
function Row({ row, year, month, refresh, index, onEditBill }) {
function Row({ row, year, month, refresh, index, onEditBill, compact = false }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
@ -1049,7 +1049,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
Sub
S
</span>
)}
<Button
@ -1155,7 +1155,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
</TableCell>
{/* Previous month paid */}
<TableCell className="tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80">
<TableCell className={cn('tracker-number w-[10%] py-3 text-right text-[13px] font-medium text-muted-foreground/80', compact && '2xl:hidden')}>
{row.previous_month_paid > 0 ? fmt(row.previous_month_paid) : '—'}
</TableCell>
@ -1252,7 +1252,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
</TableCell>
{/* Notes cell (monthly state notes) */}
<TableCell className="w-[23%] py-3 border-l border-border pl-4">
<TableCell className={cn(compact ? 'w-[16%]' : 'w-[23%]', 'py-3 border-l border-border pl-4')}>
<NotesCell row={{ ...row, year, month }} refresh={refresh} />
</TableCell>
</TableRow>
@ -1621,7 +1621,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
}
// Bucket
function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
function Bucket({ label, rows, year, month, refresh, onEditBill, loading, compact = false }) {
// 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);
@ -1631,10 +1631,11 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
const cappedPaid = Number(r.paid_toward_due);
return s + (Number.isFinite(cappedPaid) ? cappedPaid : Math.min(Number(r.total_paid) || 0, threshold));
}, 0);
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
const skippedCount = rows.length - activeRows.length;
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
const allPaid = pct >= 100;
const totalOverpaid = Math.max(totalPaid - totalPaidTowardDue, 0);
const totalRemaining = Math.max(totalThreshold - totalPaidTowardDue, 0);
const skippedCount = rows.length - activeRows.length;
const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0;
const allPaid = pct >= 100;
return (
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
@ -1665,16 +1666,24 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
</span>
</div>
</div>
<span className="text-xs font-mono text-muted-foreground">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)}
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
<span>
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
)}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
{!allPaid && totalRemaining > 0 && (
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span>
)}
</span>
{allPaid && (
<span className="text-[11px] text-emerald-500">Done</span>
)}
</div>
</div>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
@ -1723,18 +1732,18 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
<div className="hidden lg:block" aria-busy={loading ? 'true' : 'false'}>
<div className="overflow-x-auto">
<Table className="min-w-[1120px]">
<Table className={cn('min-w-[1120px]', compact && '2xl:min-w-[700px]')}>
<TableHeader>
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Bill</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right">Last Month</TableHead>
<TableHead className={cn('w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80 text-right', compact && '2xl:hidden')}>Last Month</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90">Status</TableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 border-l border-border/80 pl-4">
<TableHead className="w-[8%] py-2.5" />
<TableHead className={cn('py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/90 border-l border-border/80 pl-4', compact ? 'w-[16%]' : 'w-[23%]')}>
Notes
</TableHead>
</TableRow>
@ -1782,6 +1791,7 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
refresh={refresh}
index={i}
onEditBill={onEditBill}
compact={compact}
/>
))
)}
@ -1918,6 +1928,7 @@ export default function TrackerPage() {
}, [filters, rows, search]);
const first = filteredRows.filter(r => r.bucket === '1st');
const second = filteredRows.filter(r => r.bucket === '15th');
const hasBoth = first.length > 0 && second.length > 0;
return (
<div className="space-y-5">
@ -2038,12 +2049,6 @@ export default function TrackerPage() {
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard
type="remaining"
value={summary.remaining}
label={summary.remaining_label || 'Remaining'}
hint={summary.remaining_hint}
/>
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}
@ -2116,8 +2121,12 @@ export default function TrackerPage() {
</div>
</div>
)}
{!isError && first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{!isError && second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{!isError && (first.length > 0 || second.length > 0) && (
<div className={cn('space-y-5', hasBoth && '2xl:space-y-0 2xl:grid 2xl:grid-cols-2 2xl:gap-5')}>
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} compact={hasBoth} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} compact={hasBoth} />}
</div>
)}
{/* 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.33.8.4",
"version": "0.33.8.5",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {