feat(tracker): status row border-left indicators, refined metric cards, search filter, and CSS polish

This commit is contained in:
null 2026-07-06 13:47:34 -05:00
parent df1f61d6cc
commit c41f1a8a7b
11 changed files with 149 additions and 92 deletions

View File

@ -37,18 +37,21 @@ export default function SearchFilterPanel({
<section className={cn( <section className={cn(
embedded embedded
? 'rounded-lg' ? 'rounded-lg'
: 'surface-premium', : 'overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm shadow-black/5 backdrop-blur-sm',
className, className,
)}> )}>
<div className={cn('flex flex-wrap items-center gap-3', embedded ? 'py-1' : 'px-4 py-3')}> <div className={cn(
'flex flex-wrap items-center gap-3',
embedded ? 'py-1' : 'bg-muted/25 px-3 py-2.5 sm:px-4',
)}>
<button <button
type="button" type="button"
onClick={() => onCollapsedChange?.(!collapsed)} onClick={() => onCollapsedChange?.(!collapsed)}
aria-expanded={!collapsed} aria-expanded={!collapsed}
className="group inline-flex min-w-0 flex-1 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="group inline-flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Search className="h-4 w-4" /> <Search className="h-3.5 w-3.5" />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold text-foreground">{title}</span> <span className="block text-sm font-semibold text-foreground">{title}</span>
@ -60,7 +63,7 @@ export default function SearchFilterPanel({
</button> </button>
{hasFilters && onClear && ( {hasFilters && onClear && (
<Button type="button" variant="ghost" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs"> <Button type="button" variant="outline" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
Clear Clear
</Button> </Button>
@ -71,7 +74,7 @@ export default function SearchFilterPanel({
{!collapsed && ( {!collapsed && (
<div className={cn( <div className={cn(
'space-y-3', 'space-y-3',
embedded ? 'pt-3' : 'border-t border-border/60 px-4 py-4', embedded ? 'pt-3' : 'border-t border-border/60 bg-card/45 px-3 py-3 sm:px-4',
)}> )}>
{children} {children}
</div> </div>

View File

@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface FilterChipProps { interface FilterChipProps {
@ -12,13 +13,15 @@ export function FilterChip({ active, children, onClick }: FilterChipProps) {
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-pressed={!!active}
className={cn( className={cn(
'h-8 rounded-full border px-3 text-xs font-medium transition-colors', 'inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors',
active active
? 'border-primary/50 bg-primary/15 text-primary' ? 'border-primary/50 bg-primary/15 text-primary'
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground', : 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
)} )}
> >
{active && <Check className="h-3 w-3" aria-hidden="true" />}
{children} {children}
</button> </button>
); );

View File

@ -176,7 +176,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']} onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
onDrop={dragProps?.onDrop} onDrop={dragProps?.onDrop}
className={cn( className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm', 'rounded-lg border border-border/60 bg-card/70 p-3 shadow-sm shadow-black/5',
'space-y-3 transition-colors', 'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg, isSkipped ? 'opacity-55' : rowBg,
dragProps?.isDragging && 'opacity-45', dragProps?.isDragging && 'opacity-45',
@ -325,17 +325,17 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
</div> </div>
</div> </div>
<div className="rounded-md border border-border/50 bg-muted/20"> <div className="rounded-md border border-border/50 bg-muted/15">
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact /> <PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center"> <div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5"> <div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span> <span className="text-muted-foreground">Paid </span>
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span> <span className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div> </div>
<div className="rounded-md bg-muted/45 px-2 py-1.5"> <div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
<span className="text-muted-foreground">Date </span> <span className="text-muted-foreground">Date </span>
<button <button
type="button" type="button"

View File

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { AlertCircle, ChevronDown, ChevronUp, BellOff, SkipForward, CreditCard, EyeOff } from 'lucide-react'; import { BellOff, CalendarClock, ChevronDown, ChevronUp, Clock3, CreditCard, EyeOff, SkipForward } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { fmt, localDateString } from '@/lib/utils'; import { fmt, localDateString } from '@/lib/utils';
@ -42,6 +42,7 @@ interface OverdueRowProps {
function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) { function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const threshold = row.actual_amount ?? row.expected_amount; const threshold = row.actual_amount ?? row.expected_amount;
const remaining = Math.max(0, threshold - (row.total_paid ?? 0));
async function handleSkip() { async function handleSkip() {
setLoading(true); setLoading(true);
@ -82,7 +83,7 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
} }
return ( return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-2 py-2.5 transition-colors hover:bg-card/55 sm:flex-nowrap">
{/* Bill info */} {/* Bill info */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
@ -93,27 +94,33 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
</Badge> </Badge>
)} )}
</div> </div>
<span className="text-xs font-medium text-rose-600 dark:text-rose-300"> <span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-200">
<Clock3 className="h-3 w-3" aria-hidden="true" />
{daysOverdueLabel(row.due_date)} {daysOverdueLabel(row.due_date)}
</span> </span>
</div> </div>
{/* Amount */} {/* Amount */}
<span className="shrink-0 font-mono text-sm font-semibold text-rose-600 dark:text-rose-300"> <div className="flex shrink-0 flex-col items-end leading-tight">
{fmt(threshold)} <span className="tracker-number text-sm font-semibold text-foreground">
{fmt(asDollars(remaining > 0 ? remaining : threshold))}
</span> </span>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/65">
remaining
</span>
</div>
{/* Actions */} {/* Actions */}
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 gap-1.5 border-rose-500/40 px-2.5 text-xs text-rose-600 hover:border-rose-500/70 hover:bg-rose-500/[0.08] hover:text-rose-700 dark:text-rose-300 dark:hover:text-rose-200" className="h-7 gap-1.5 px-2.5 text-xs"
disabled={loading} disabled={loading}
onClick={() => onPayNow(row)} onClick={() => onPayNow(row)}
> >
<CreditCard className="h-3 w-3" /> <CreditCard className="h-3 w-3" />
Pay Now Pay now
</Button> </Button>
<Button <Button
@ -198,25 +205,32 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
return ( return (
<Collapsible open={isOpen} onOpenChange={setOpen}> <Collapsible open={isOpen} onOpenChange={setOpen}>
<div className="surface-premium overflow-hidden border-rose-500/30 bg-rose-500/[0.055] dark:border-rose-300/25 dark:bg-rose-400/[0.05]"> <div className="surface-premium overflow-hidden border-amber-500/30 bg-amber-500/[0.055] dark:border-amber-300/25 dark:bg-amber-400/[0.05]">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 px-4 py-3"> <div className="flex items-center gap-2 px-3 py-3 sm:px-4">
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
type="button" type="button"
aria-expanded={isOpen} aria-expanded={isOpen}
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40" className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/45"
> >
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" /> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-200">
<span className="text-sm font-semibold text-foreground"> <CalendarClock className="h-4 w-4" aria-hidden="true" />
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
</span> </span>
<span className="font-mono text-sm font-semibold text-rose-600 dark:text-rose-300"> <span className="min-w-0">
<span className="block text-sm font-semibold text-foreground">
Review overdue bills
</span>
<span className="block truncate text-xs text-muted-foreground">
{`${overdueRows.length} ${overdueRows.length === 1 ? 'item' : 'items'} ready for pay, skip, or snooze`}
</span>
</span>
<span className="tracker-number shrink-0 text-sm font-semibold text-foreground">
{fmt(asDollars(totalOverdue))} {fmt(asDollars(totalOverdue))}
</span> </span>
{snoozedRows.length > 0 && ( {snoozedRows.length > 0 && (
<span className="text-xs text-muted-foreground"> <span className="hidden text-xs text-muted-foreground sm:inline">
({snoozedRows.length} snoozed) ({snoozedRows.length} snoozed)
</span> </span>
)} )}
@ -240,7 +254,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
{/* Bill rows */} {/* Bill rows */}
<CollapsibleContent> <CollapsibleContent>
<div className="divide-y divide-border/40 px-4 pb-2"> <div className="border-t border-border/50 px-2 pb-2 pt-1 sm:px-3">
{overdueRows.map(row => ( {overdueRows.map(row => (
<OverdueRow <OverdueRow
key={row.id} key={row.id}

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { Loader2, AlertCircle } from 'lucide-react'; import { AlertCircle, CheckCircle2, Circle, Clock3, Loader2, SkipForward } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils'; import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
import type { TrackerStatus } from '@/types'; import type { TrackerStatus } from '@/types';
@ -13,9 +13,24 @@ interface StatusBadgeProps {
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }: StatusBadgeProps) { export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }: StatusBadgeProps) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]); const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
const StatusIcon = useMemo(() => {
switch (status) {
case 'paid':
case 'autodraft':
return CheckCircle2;
case 'late':
case 'missed':
return AlertCircle;
case 'due_soon':
return Clock3;
case 'skipped':
return SkipForward;
default:
return Circle;
}
}, [status]);
const isSkipped = status === 'skipped'; const isSkipped = status === 'skipped';
const isUrgent = status === 'late' || status === 'missed';
const canClick = clickable && !isSkipped && !loading; const canClick = clickable && !isSkipped && !loading;
return ( return (
@ -24,26 +39,27 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
disabled={!canClick || loading} disabled={!canClick || loading}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold', 'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] rounded-full font-semibold',
'uppercase tracking-wide whitespace-nowrap', 'uppercase tracking-wide whitespace-nowrap',
'transition-all duration-150', 'transition-colors duration-150',
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs', (status === 'late' || status === 'missed') && 'px-2.5 py-1 text-xs',
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm', canClick && 'cursor-pointer hover:shadow-sm',
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40', canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40', canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
loading && 'opacity-60 cursor-wait', loading && 'opacity-60 cursor-wait',
meta.cls, meta.cls,
)} )}
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined} title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
aria-label={`${meta.label}${canClick ? (isPaidStatus(status) ? ', click to mark unpaid' : ', click to mark paid') : ''}`}
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="h-3 w-3 mr-1 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
{meta.label} {meta.label}
</> </>
) : ( ) : (
<> <>
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />} <StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
{meta.label} {meta.label}
</> </>
)} )}

View File

@ -15,16 +15,16 @@ interface CardDef {
const CARD_DEFS: Record<CardType, CardDef> = { const CARD_DEFS: Record<CardType, CardDef> = {
starting: { starting: {
label: 'Starting', label: 'Starting cash',
icon: TrendingUp, icon: TrendingUp,
valueClass: 'text-foreground', valueClass: 'text-foreground',
tone: 'neutral', tone: 'neutral',
activateWhen: () => true, activateWhen: () => true,
}, },
paid: { paid: {
label: 'Total Paid', label: 'Paid',
icon: CheckCircle2, icon: CheckCircle2,
valueClass: 'text-emerald-600 dark:text-emerald-200', valueClass: 'text-foreground',
tone: 'good', tone: 'good',
activateWhen: (v) => v > 0, activateWhen: (v) => v > 0,
}, },
@ -32,13 +32,13 @@ const CARD_DEFS: Record<CardType, CardDef> = {
label: 'Remaining', label: 'Remaining',
icon: Clock, icon: Clock,
valueClass: 'text-foreground', valueClass: 'text-foreground',
tone: 'info', tone: 'warn',
activateWhen: () => true, activateWhen: (v) => v > 0,
}, },
overdue: { overdue: {
label: 'Overdue', label: 'Overdue',
icon: AlertCircle, icon: AlertCircle,
valueClass: 'text-red-500 dark:text-rose-200', valueClass: 'text-foreground',
tone: 'danger', tone: 'danger',
activateWhen: (v) => v > 0, activateWhen: (v) => v > 0,
}, },
@ -58,12 +58,12 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
switch (direction) { switch (direction) {
case 'up': case 'up':
icon = '↑'; icon = '↑';
color = 'text-emerald-500'; color = 'text-emerald-600 dark:text-emerald-300';
text = `${icon} ${percent_change}%`; text = `${icon} ${percent_change}%`;
break; break;
case 'down': case 'down':
icon = '↓'; icon = '↓';
color = 'text-red-500'; color = 'text-rose-600 dark:text-rose-300';
text = `${icon} ${Math.abs(percent_change)}%`; text = `${icon} ${Math.abs(percent_change)}%`;
break; break;
default: default:
@ -73,8 +73,8 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
} }
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1">
<span className={`text-lg font-bold ${color}`}> <span className={`tracker-number text-lg font-bold ${color}`}>
{text} {text}
</span> </span>
<span className="text-[10px] text-muted-foreground whitespace-nowrap"> <span className="text-[10px] text-muted-foreground whitespace-nowrap">
@ -105,7 +105,7 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
icon={Icon} icon={Icon}
label={displayLabel} label={displayLabel}
value={( value={(
<span className={cn(isActive ? def.valueClass : 'text-foreground')}> <span className={cn('block tabular-nums', isActive ? def.valueClass : 'text-foreground')}>
{fmt(value)} {fmt(value)}
</span> </span>
)} )}
@ -113,11 +113,11 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
action={type === 'starting' && onEdit ? ( action={type === 'starting' && onEdit ? (
<button <button
onClick={onEdit} onClick={onEdit}
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" className="ml-auto grid h-7 w-7 place-items-center rounded-lg border border-border/60 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
title="Edit monthly starting amounts" title="Edit monthly starting amounts"
aria-label="Edit monthly starting amounts" aria-label="Edit monthly starting amounts"
> >
<Settings2 className="h-4 w-4" /> <Settings2 className="h-3.5 w-3.5" />
</button> </button>
) : undefined} ) : undefined}
/> />
@ -134,7 +134,7 @@ export function TrendCard({ trend }: { trend?: TrendInfo | null }) {
icon={TrendingUp} icon={TrendingUp}
label="3-Month Trend" label="3-Month Trend"
value={( value={(
<span className="flex min-h-10 items-center justify-center"> <span className="flex min-h-8 items-center">
<TrendIndicator trend={trend} /> <TrendIndicator trend={trend} />
</span> </span>
)} )}

View File

@ -223,10 +223,10 @@ export function TrackerBucket({
} }
return ( return (
<div className="surface-premium overflow-hidden"> <div className="table-surface overflow-hidden">
{/* Bucket header */} {/* Bucket header */}
<div className="flex flex-col gap-2 bg-muted/35 px-3 py-3 border-b border-border/80 sm:px-5 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 border-b border-border/70 bg-card/60 px-3 py-3 sm:px-5 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground"> <span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
@ -239,7 +239,7 @@ export function TrackerBucket({
)} )}
</div> </div>
<div className="flex min-w-[7rem] items-center gap-2"> <div className="flex min-w-[7rem] items-center gap-2">
<div className="h-1.5 w-16 rounded-full bg-border overflow-hidden sm:w-24"> <div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
<div <div
className={cn( className={cn(
'h-full rounded-full transition-all duration-700', 'h-full rounded-full transition-all duration-700',
@ -254,7 +254,7 @@ export function TrackerBucket({
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
<span className="whitespace-nowrap"> <span className="tracker-number whitespace-nowrap">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}> <span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(asDollars(totalPaidTowardDue))} {fmt(asDollars(totalPaidTowardDue))}
</span> </span>
@ -308,10 +308,10 @@ export function TrackerBucket({
</AlertDialog> </AlertDialog>
<LayoutGroup id={`tracker-bucket-mobile-${label}`}> <LayoutGroup id={`tracker-bucket-mobile-${label}`}>
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}> <div className="grid gap-2.5 bg-background/20 p-2.5 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
{loading ? ( {loading ? (
Array.from({ length: 3 }).map((_, i) => ( Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse"> <div key={i} className="animate-pulse rounded-lg border border-border/60 bg-card/65 p-3">
<div className="flex min-w-0 items-start justify-between gap-3"> <div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@ -334,7 +334,7 @@ export function TrackerBucket({
</div> </div>
)) ))
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground"> <div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
No bills match try adjusting your filters. No bills match try adjusting your filters.
</div> </div>
) : ( ) : (
@ -361,7 +361,7 @@ export function TrackerBucket({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}> <Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
<TableHeader> <TableHeader>
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30"> <TableRow className="border-border/80 bg-muted/20 hover:bg-muted/20">
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead> <SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
{showColumn('due') && ( {showColumn('due') && (
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead> <SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>

View File

@ -120,14 +120,18 @@ export function MetricCard({
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}> <div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{Icon && <Icon className="h-4 w-4 shrink-0 text-current opacity-80" />} {Icon && (
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08] text-current">
<Icon className="h-3.5 w-3.5 opacity-90" />
</span>
)}
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> <p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{label} {label}
</p> </p>
</div> </div>
{action} {action}
</div> </div>
<p className="metric-value mt-3 text-2xl leading-none sm:text-[1.75rem]">{value}</p> <p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">{value}</p>
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>} {hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
</div> </div>
); );

View File

@ -162,16 +162,16 @@
} }
.metric-card { .metric-card {
@apply relative overflow-hidden rounded-xl border border-border/75 bg-card/95 px-5 py-4 shadow-sm shadow-black/10; @apply relative min-h-[7.75rem] overflow-hidden rounded-xl border border-border/75 bg-card/95 px-4 py-4 shadow-sm shadow-black/10 sm:px-5;
} }
.metric-card::before { .metric-card::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0 0 auto; inset: 0 0 auto;
height: 3px; height: 2px;
background: linear-gradient(90deg, oklch(var(--primary) / 0.85), oklch(var(--accent) / 0.68)); background: currentColor;
opacity: 0.76; opacity: 0.24;
} }
/* Stat cards */ /* Stat cards */
@ -194,6 +194,7 @@
.metric-value { .metric-value {
@apply tracker-number font-bold tracking-tight text-foreground; @apply tracker-number font-bold tracking-tight text-foreground;
overflow-wrap: anywhere;
} }

View File

@ -41,20 +41,20 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
); );
export const ROW_STATUS_CLS: Record<string, string> = { export const ROW_STATUS_CLS: Record<string, string> = {
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]', paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/[0.025] dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]', autodraft: 'border-l-2 border-l-teal-500/45 bg-teal-500/[0.025] dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
upcoming: '', upcoming: 'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]', due_soon: 'border-l-2 border-l-amber-400/65 bg-amber-400/[0.055] dark:bg-amber-300/[0.02] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25', late: 'border-l-2 border-l-orange-400/80 bg-orange-500/[0.09] ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30', missed: 'border-l-2 border-l-rose-400/80 bg-rose-500/[0.1] ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/[0.075] dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
}; };
export const STATUS_META = { export const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' }, paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' }, upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' }, due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
late: { label: 'Late', cls: 'bg-orange-500/20 text-orange-700 border border-orange-500/50 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' }, late: { label: 'Late', cls: 'bg-orange-500/15 text-orange-700 border border-orange-500/45 shadow-sm shadow-orange-950/10 dark:bg-orange-400/20 dark:text-orange-100 dark:border-orange-300/50' },
missed: { label: 'Missed', cls: 'bg-rose-500/20 text-rose-700 border border-rose-500/55 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' }, missed: { label: 'Missed', cls: 'bg-rose-500/15 text-rose-700 border border-rose-500/45 shadow-sm shadow-rose-950/10 dark:bg-rose-400/20 dark:text-rose-100 dark:border-rose-300/50' },
autodraft: { label: 'Autodraft', cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28' }, autodraft: { label: 'Autodraft', cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
}; };

View File

@ -1135,7 +1135,7 @@ export default function TrackerPage() {
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {/* ── Summary cards (backend already excludes skipped from totals) ── */}
{showSummaryCards && loading ? ( {showSummaryCards && loading ? (
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl: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" /> <Skeleton variant="card" className="h-32" />
@ -1144,7 +1144,7 @@ export default function TrackerPage() {
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null} {summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
</div> </div>
) : showSummaryCards ? ( ) : showSummaryCards ? (
<div className="grid grid-cols-2 gap-3 lg:flex"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex">
{bankTracking?.enabled ? ( {bankTracking?.enabled ? (
<button <button
type="button" type="button"
@ -1158,24 +1158,31 @@ export default function TrackerPage() {
)} )}
title="Click to see income breakdown" title="Click to see income breakdown"
> >
<div className="flex items-center gap-2 mb-3"> <div className="mb-3 flex items-start justify-between gap-3">
<Landmark className={cn('h-4 w-4', Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-500' : 'text-destructive')} /> <div className="flex min-w-0 items-center gap-2">
<span className={cn(
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08]',
Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive',
)}>
<Landmark className="h-3.5 w-3.5" />
</span>
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground"> <p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{bankTracking.account_name} {bankTracking.account_name}
</p> </p>
</div>
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium"> <span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
<StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" /> <StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" />
Live Live
</span> </span>
</div> </div>
<p className="text-[1.75rem] font-bold tracking-tight font-mono leading-none text-foreground"> <p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">
{fmt(bankTracking.effective_balance ?? 0)} {fmt(bankTracking.effective_balance ?? 0)}
</p> </p>
<p className="mt-2 text-[11px] text-muted-foreground"> <p className="mt-2 text-xs leading-5 text-muted-foreground">
{Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills {Number(bankTracking.remaining ?? 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
</p> </p>
{bankTracking.last_updated && ( {bankTracking.last_updated && (
<p className="mt-1 text-[10px] text-muted-foreground/60"> <p className="mt-1 text-[10px] leading-4 text-muted-foreground/70">
as of {fmtBalanceAge(bankTracking.last_updated)} as of {fmtBalanceAge(bankTracking.last_updated)}
</p> </p>
)} )}
@ -1218,14 +1225,23 @@ export default function TrackerPage() {
{/* Compact month-progress line — quick "how far through the month am I?" */} {/* Compact month-progress line — quick "how far through the month am I?" */}
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => { {showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0); const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
const progress = Math.min(100, Math.max(0, (Number(summary.paid_toward_due || 0) / Number(summary.total_expected || 1)) * 100));
return ( return (
<p className="px-1 -mt-1 text-[11px] text-muted-foreground"> <div className="-mt-1 rounded-xl border border-border/65 bg-card/55 px-3 py-2 shadow-sm shadow-black/5">
<span className="font-mono font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span> <div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
<p>
<span className="tracker-number font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
{' of '} {' of '}
<span className="font-mono">{fmt(summary.total_expected)}</span> <span className="tracker-number">{fmt(summary.total_expected)}</span>
{' paid'} {' paid'}
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>} {billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
</p> </p>
<span className="tracker-number font-semibold text-foreground">{Math.round(progress)}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary/75" style={{ width: `${progress}%` }} />
</div>
</div>
); );
})()} })()}