style: stronger late/missed payment visibility

- Tracker rows, mobile rows: alert icons, left-edge marker, stronger row tinting
- Calendar grid and detail dialog: brighter dots, count badges
- StatusBadge: stronger orange/rose badges for late/missed
- Shared badge component: clearer contrast
This commit is contained in:
null 2026-05-28 23:42:46 -05:00
parent 6c1b02e613
commit 792980b8ba
6 changed files with 48 additions and 18 deletions

View File

@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState } from 'react';
import { Pencil, Settings2 } from 'lucide-react';
import { AlertCircle, Pencil, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@ -17,8 +17,8 @@ const ROW_STATUS_CLS = {
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
late: 'bg-orange-400/[0.08] dark:bg-orange-300/[0.014]',
missed: 'bg-red-400/[0.08] dark:bg-rose-300/[0.01]',
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',
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',
};
function paymentDateForTrackerMonth(year, month, dueDay) {
@ -146,6 +146,7 @@ export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year
}, [isSkipped, isPaidByThreshold, row.status]);
const rowBg = useMemo(() => isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''), [isSkipped, effectiveStatus]);
const isUrgent = effectiveStatus === 'late' || effectiveStatus === 'missed';
const remaining = useMemo(() => Math.max((threshold || 0) - (row.total_paid || 0), 0), [threshold, row.total_paid]);
async function handleQuickPay() {
@ -166,6 +167,7 @@ export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year
className={cn(
'rounded-lg border border-border/70 bg-card/90 p-3 shadow-sm shadow-black/10',
'space-y-3 transition-colors',
isUrgent && 'border-border/80 shadow-md shadow-rose-950/10',
isSkipped ? 'opacity-55' : rowBg,
)}
style={{ animationDelay: `${index * 40}ms` }}
@ -199,6 +201,12 @@ export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year
{row.monthly_notes}
</p>
)}
{isUrgent && (
<p className="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-rose-500 dark:text-rose-200">
<AlertCircle className="h-3.5 w-3.5" />
Needs attention
</p>
)}
</div>
<StatusBadge status={effectiveStatus} />
</div>

View File

@ -1,24 +1,28 @@
import React, { useMemo } from 'react';
import { AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 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' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30 dark:bg-orange-300/10 dark:text-orange-200 dark:border-orange-300/26' },
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30 dark:bg-rose-300/10 dark:text-rose-200 dark:border-rose-300/26' },
late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
export const StatusBadge = React.memo(function StatusBadge({ status }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
const isUrgent = status === 'late' || status === 'missed';
return (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
'uppercase tracking-wide whitespace-nowrap',
isUrgent && 'gap-1.5 py-1 text-xs',
meta.cls,
)}>
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
{meta.label}
</span>
);

View File

@ -13,8 +13,8 @@ const badgeVariants = cva(
outline: 'text-foreground',
// Bill status variants
paid: 'bg-emerald-500/20 text-emerald-300 border-emerald-400/35',
late: 'bg-orange-500/20 text-orange-300 border-orange-400/35',
missed: 'bg-red-500/20 text-red-300 border-red-400/35',
late: 'bg-orange-500/30 text-orange-100 border-orange-300/60 shadow-sm shadow-orange-950/10',
missed: 'bg-rose-500/30 text-rose-100 border-rose-300/60 shadow-sm shadow-rose-950/10',
due_soon: 'bg-yellow-500/20 text-yellow-200 border-yellow-400/35',
upcoming: 'bg-slate-400/20 text-slate-200 border-slate-300/30',
autodraft: 'bg-amber-500/20 text-amber-200 border-amber-400/35',

View File

@ -46,7 +46,8 @@ function displayStatus(status) {
function statusTone(status) {
if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
if (status === 'late' || status === 'missed') return 'border-destructive/30 bg-destructive/15 text-destructive';
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';
return 'border-primary/30 bg-primary/15 text-primary';
}
@ -204,7 +205,7 @@ function DayIndicators({ day, moneyMarker }) {
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-400 ring-1 ring-emerald-400/30" title="Paid" />}
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
{hasMissed && <span className="h-1.5 w-1.5 rounded-full bg-destructive" title="Missed or late" />}
{hasMissed && <span className="h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-rose-500/40" title="Missed or late" />}
</div>
);
}
@ -252,7 +253,7 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
hasActivity && 'bg-primary/[0.06] hover:bg-accent/70',
isPaidDay && 'bg-emerald-500/[0.10]',
hasMissed && 'bg-destructive/[0.10]',
hasMissed && 'border-rose-400/40 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.12]',
isSelected && 'ring-2 ring-primary ring-inset bg-primary/[0.09]',
)}
aria-label={`View ${fmtDate(day.date)}`}
@ -265,7 +266,12 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
{day.day}
</span>
{summary.due_count > 0 && (
<span className="tracker-number rounded border border-border/60 bg-background/90 px-1 text-[10px] font-semibold text-foreground/70">
<span className={cn(
'tracker-number rounded border px-1 text-[10px] font-semibold',
hasMissed
? 'border-rose-400/60 bg-rose-500/25 text-rose-700 dark:text-rose-100'
: 'border-border/60 bg-background/90 text-foreground/70',
)}>
{summary.due_count}
</span>
)}
@ -450,7 +456,14 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
) : (
<div className="space-y-2">
{day.bills_due.map(bill => (
<div key={bill.bill_id} className="rounded-lg border border-border/70 bg-background/75 p-3">
<div
key={bill.bill_id}
className={cn(
'rounded-lg border border-border/70 bg-background/75 p-3',
(bill.status === 'late' || bill.status === 'missed') &&
'border-rose-400/40 bg-rose-500/[0.10] ring-1 ring-inset ring-rose-400/20',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{bill.name}</p>

View File

@ -56,16 +56,16 @@ const ROW_STATUS_CLS = {
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
late: 'bg-orange-400/[0.08] dark:bg-orange-300/[0.014]',
missed: 'bg-red-400/[0.08] dark:bg-rose-300/[0.01]',
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',
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',
};
const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 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' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
late: { label: 'Late', cls: 'bg-orange-400/15 text-orange-500 border border-orange-400/30 dark:bg-orange-300/10 dark:text-orange-200 dark:border-orange-300/26' },
missed: { label: 'Missed', cls: 'bg-red-400/15 text-red-500 border border-red-400/30 dark:bg-rose-300/10 dark:text-rose-200 dark:border-rose-300/26' },
late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
@ -263,6 +263,7 @@ const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
const isSkipped = status === 'skipped';
const isUrgent = status === 'late' || status === 'missed';
const canClick = clickable && !isSkipped && !loading;
return (
@ -274,6 +275,7 @@ const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
'uppercase tracking-wide whitespace-nowrap',
'transition-all duration-150',
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
canClick && 'cursor-pointer hover:scale-105 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-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
@ -288,7 +290,10 @@ const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick
{meta.label}
</>
) : (
meta.label
<>
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
{meta.label}
</>
)}
</button>
);

View File

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