fix: mobile tracker row and bucket rendering polish

This commit is contained in:
null 2026-06-07 14:58:37 -05:00
parent e1082145ab
commit 72d95065d0
3 changed files with 62 additions and 29 deletions

View File

@ -3,6 +3,14 @@
### ✨ Added ### ✨ Added
- **Autopay trust indicator** — Autopay is no longer treated as a binary flag. The tracker row's AP badge now shows a confidence score derived from the last 12 months of payment history: "✓ 12/12 successful" on a healthy bill, or "⚠ 11/12" in amber when even one payment was recorded as an autopay failure. A clock nudge appears when autopay hasn't been manually confirmed in over 90 days (or never). Hovering the badge shows a tooltip with full detail: success rate, last failure date and notes, and verification status. Inside BillModal, an autopay trust panel appears below the Autodraft Status select for existing bills: it surfaces the same confidence rate, shows a staleness warning if verification is overdue, and provides a "Mark verified" button that calls the new `POST /api/bills/:id/verify-autopay` endpoint and updates the timestamp in place. New migration v0.99 adds `bills.autopay_verified_at` (timestamp of last user confirmation) and `payments.autopay_failure` (flag marking a payment that was made because autopay silently failed). The PaymentModal edit dialog now shows an "Autopay failed — paid manually" checkbox on autopay-enabled bills or when the payment method is set to autopay, enabling retroactive flagging of past failures.
- **Trend sparkline on amount column** — Each tracker row now renders a 44×16 px inline SVG polyline to the right of the expected amount showing the last 6 months of actual payment totals, oldest-to-newest, normalized to the row's min/max range. No chart library is required — the sparkline is computed server-side in `trackerService.fetchSparklines` (a single batched `GROUP BY bill_id, month_str` query for all bill IDs at once) and rendered as a `<polyline>` on the client. The chart appears only when two or more months of data exist.
- **"Changed" badge on drifted bills** — When the drift service detects that a bill's actual amount differs from the prior month's by more than the configured threshold, the amount column in the tracker now shows a small amber "Changed" badge with a trending-up icon alongside the sparkline. This reuses the existing `useDriftReport()` data already fetched by TrackerPage — no new API call is needed. The set of drifted bill IDs is derived once in TrackerPage, passed through TrackerBucket, and evaluated per row as a boolean `isDrifted` prop.
- **Cancellation tracker** — When deactivating a bill from the Bills page, the confirmation dialog now includes an optional reason dropdown: "Moved to spouse", "Switched providers", "Paid off", "Cancelled", or "Other". The selected reason is stored in the new `bills.inactive_reason` column (migration v0.99) alongside `bills.inactivated_at`. The `PUT /api/bills/:id` endpoint already writes both fields when the active flag flips from 1 to 0.
- **Tracker table sorting** — The Tracker page now supports URL-backed sorting by bill name, due date, expected amount, last-month paid, paid amount, remaining amount, paid date, and status. Desktop table headers are clickable with active direction indicators, while the filter bar provides a compact sort selector for mobile and tablet. Status sorting uses the tracker lifecycle order (missed, late, due soon, upcoming, autodraft, paid, skipped) instead of plain alphabetical labels, and manual bill reordering is paused while a sorted view is active. - **Tracker table sorting** — The Tracker page now supports URL-backed sorting by bill name, due date, expected amount, last-month paid, paid amount, remaining amount, paid date, and status. Desktop table headers are clickable with active direction indicators, while the filter bar provides a compact sort selector for mobile and tablet. Status sorting uses the tracker lifecycle order (missed, late, due soon, upcoming, autodraft, paid, skipped) instead of plain alphabetical labels, and manual bill reordering is paused while a sorted view is active.
- **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated. - **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated.
@ -32,6 +40,8 @@
### 🔧 Changed ### 🔧 Changed
- **React 19 upgrade** — Upgraded from React 18.3.1 to React 19. The `useOptimistic` polyfill in `client/hooks/useOptimistic.js` was deleted in favour of the native React 19 hook. `BillModal` was refactored from a manual `handleSubmit` + `useState(busy)` pattern to `useActionState` with a form `action` prop — the async action handles validation, bill creation/update, template save, and error toasts without a separate busy flag. All 15 shadcn/ui component files (`button`, `badge`, `card`, `checkbox`, `collapsible`, `dialog`, `input`, `label`, `select`, `separator`, `Skeleton`, `table`, `tabs`, `theme-toggle`, `tooltip`) had `React.forwardRef(...)` replaced with plain function components accepting `ref` as a named prop, removing the deprecated pattern.
- **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount. - **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount.
- **Subscription recommendation review details** — Recommendation scoring now includes an ambiguity evidence bucket for broad merchants and very short service names such as Amazon, Apple, Google, Walmart, Disney, Microsoft, Target, and Max. Exact known/user bank descriptors remain strong, but weaker broad name/domain/slang matches receive a confidence penalty and surface a "Review" badge with reasons. Recommendation payloads now include the matched transaction previews, and the Subscriptions page has a Details dialog showing identity evidence, price evidence, cadence evidence, ambiguity notes, amount range, catalog pricing, source accounts, and the specific bank transactions behind the recommendation before the user tracks, declines, or links it to an existing bill. - **Subscription recommendation review details** — Recommendation scoring now includes an ambiguity evidence bucket for broad merchants and very short service names such as Amazon, Apple, Google, Walmart, Disney, Microsoft, Target, and Max. Exact known/user bank descriptors remain strong, but weaker broad name/domain/slang matches receive a confidence penalty and surface a "Review" badge with reasons. Recommendation payloads now include the matched transaction previews, and the Subscriptions page has a Details dialog showing identity evidence, price evidence, cadence evidence, ambiguity notes, amount range, catalog pricing, source accounts, and the specific bank transactions behind the recommendation before the user tracks, declines, or links it to an existing bill.

View File

@ -1,5 +1,5 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { ArrowDown, ArrowUp, GripVertical, Pencil } from 'lucide-react'; import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate } from '@/lib/utils';
@ -9,7 +9,6 @@ import {
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
@ -19,13 +18,33 @@ import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell'; import { NotesCell } from '@/components/tracker/NotesCell';
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) { function MiniSparkline({ values }) {
if (!values || values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const width = 44;
const height = 14;
const points = values.map((value, index) => {
const x = (index / (values.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="mt-1 opacity-60" aria-hidden="true">
<polyline points={points} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
}
export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }) {
const amountRef = useRef(null); const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null); const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const [quickPaySaving, setQuickPaySaving] = useState(false);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
@ -43,14 +62,18 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const summary = paymentSummary(row, threshold); const summary = paymentSummary(row, threshold);
async function handleQuickPay() { async function handleQuickPay() {
if (quickPaySaving) return;
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
setQuickPaySaving(true);
try { try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate }); await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Payment added'); toast.success('Payment added');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(err.message);
} finally {
setQuickPaySaving(false);
} }
} }
@ -246,6 +269,13 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
<p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}> <p className={cn('tracker-number mt-0.5 text-sm font-semibold', row.actual_amount != null ? 'text-amber-300' : 'text-foreground')}>
{fmt(threshold)} {fmt(threshold)}
</p> </p>
{isDrifted && (
<span className="mt-0.5 inline-flex items-center gap-0.5 text-[10px] font-semibold text-amber-500">
<TrendingUp className="h-2.5 w-2.5" />
Changed
</span>
)}
<MiniSparkline values={row.sparkline} />
</div> </div>
<div> <div>
<p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p> <p className="uppercase tracking-wide text-muted-foreground/60">Last Month</p>
@ -303,13 +333,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60" className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount" title="Payment amount"
aria-label={`${row.name} payment amount`} aria-label={`${row.name} payment amount`}
disabled={quickPaySaving}
/> />
<Button <Button
size="sm" variant="default" size="sm" variant="default"
onClick={handleQuickPay} onClick={handleQuickPay}
disabled={quickPaySaving}
className="h-8 px-3 text-xs font-semibold" className="h-8 px-3 text-xs font-semibold"
> >
Add {quickPaySaving ? 'Adding...' : 'Add'}
</Button> </Button>
</div> </div>
)} )}
@ -350,17 +382,6 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
/> />
)} )}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
<AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}> <AlertDialog open={confirmUnpay} onOpenChange={setConfirmUnpay}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>

View File

@ -110,18 +110,20 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15"> <div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
{/* Bucket header */} {/* Bucket header */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/35 border-b border-border/80"> <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 items-center gap-3"> <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground"> <div className="flex min-w-0 items-center gap-2">
{label} <span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
</span> {label}
{skippedCount > 0 && (
<span className="text-[10px] text-muted-foreground/60">
({skippedCount} skipped)
</span> </span>
)} {skippedCount > 0 && (
<div className="flex items-center gap-2"> <span className="shrink-0 text-[10px] text-muted-foreground/60">
<div className="h-1.5 w-24 rounded-full bg-border overflow-hidden"> ({skippedCount} skipped)
</span>
)}
</div>
<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 <div
className={cn( className={cn(
'h-full rounded-full transition-all duration-700', 'h-full rounded-full transition-all duration-700',
@ -135,8 +137,8 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
<span> <span className="whitespace-nowrap">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}> <span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)} {fmt(totalPaidTowardDue)}
</span> </span>