bill tracker futurue

This commit is contained in:
null 2026-05-28 02:09:49 -05:00
parent fa60ea8fbd
commit e8218a3dd8
3 changed files with 213 additions and 61 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X } from 'lucide-react';
import { toast } from 'sonner';
@ -448,7 +448,7 @@ function paymentSummary(row, threshold) {
};
}
function PaymentProgress({ row, threshold, onOpen, compact = false }) {
function PaymentProgress({ row, threshold, onOpen, onMarkFullAmount, compact = false }) {
const summary = paymentSummary(row, threshold);
const barTone = summary.remaining === 0
? 'bg-emerald-500'
@ -463,33 +463,47 @@ function PaymentProgress({ row, threshold, onOpen, compact = false }) {
return fmt(summary.paidTowardDue);
})();
const showQuickFix = onMarkFullAmount && summary.partial && summary.paid > 0;
return (
<button
type="button"
onClick={onOpen}
className={cn(
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
compact ? 'p-2' : 'px-2 py-1.5',
)}
title="View payment history"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
{amountLabel}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{summary.count}×
</span>
<div>
<button
type="button"
onClick={onOpen}
className={cn(
'w-full rounded-md text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
compact ? 'p-2' : 'px-2 py-1.5',
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', barTone)}
style={{ width: `${summary.percent}%` }}
/>
</div>
</button>
title="View payment history"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className={cn('font-mono', summary.paid > 0 ? 'text-emerald-500' : 'text-muted-foreground')}>
{amountLabel}
</span>
{summary.count > 1 && (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{summary.count}×
</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full transition-all', barTone)}
style={{ width: `${summary.percent}%` }}
/>
</div>
</button>
{showQuickFix && (
<button
type="button"
onClick={onMarkFullAmount}
className="mt-0.5 w-full rounded px-2 py-0.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:bg-accent/50 hover:text-foreground"
title={`Set ${fmt(summary.paidTowardDue)} as the full amount due this month`}
>
{fmt(summary.paidTowardDue)} is the full amount
</button>
)}
</div>
);
}
@ -1212,10 +1226,14 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
const [confirmUnpay, setConfirmUnpay] = useState(false);
const [loading, setLoading] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false);
const [optimisticActual, setOptimisticActual] = useState(undefined);
const [showUpdateNudge, setShowUpdateNudge] = useState(false);
const [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition();
// Effective amount threshold for this bill this month:
// actual_amount (if set by monthly override) takes priority over the template expected_amount.
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
// Effective amount threshold: optimistic override monthly override template default.
const effectiveActual = optimisticActual !== undefined ? optimisticActual : row.actual_amount;
const threshold = effectiveActual != null ? effectiveActual : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isSkipped = !!row.is_skipped;
@ -1290,6 +1308,55 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
performTogglePaid();
}
async function handleMarkFullAmount() {
const newActual = summary.paidTowardDue;
setOptimisticActual(newActual);
try {
await api.saveBillMonthlyState(row.id, {
year, month,
actual_amount: newActual,
notes: row.monthly_notes || null,
is_skipped: row.is_skipped,
});
setNudgeAmount(newActual);
setShowUpdateNudge(true);
refresh?.();
} catch (err) {
setOptimisticActual(undefined);
toast.error(err.message || 'Failed to update amount');
}
}
function handleUpdateTemplate() {
const amount = nudgeAmount;
setShowUpdateNudge(false);
startTransition(async () => {
try {
await api.updateBill(row.id, { expected_amount: amount });
toast.success(`Default updated to ${fmt(amount)}`);
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to update default');
}
});
}
async function handleApplySuggestion(amount) {
setOptimisticActual(amount);
try {
await api.saveBillMonthlyState(row.id, {
year, month,
actual_amount: amount,
notes: row.monthly_notes || null,
is_skipped: row.is_skipped,
});
refresh?.();
} catch (err) {
setOptimisticActual(undefined);
toast.error(err.message || 'Failed to apply suggestion');
}
}
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
try {
@ -1382,15 +1449,28 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Expected / Actual — shows actual_amount in amber when it overrides the template */}
<TableCell className="w-[10%] py-3 text-right font-mono text-sm">
{row.actual_amount != null ? (
{effectiveActual != null ? (
<span
className="text-amber-500"
title={`Monthly override. Template default: ${fmt(row.expected_amount)}`}
>
{fmt(row.actual_amount)}
{fmt(effectiveActual)}
</span>
) : (
<span className="text-muted-foreground">{fmt(row.expected_amount)}</span>
<div className="flex flex-col items-end gap-0.5">
<span className="text-muted-foreground">{fmt(row.expected_amount)}</span>
{row.amount_suggestion?.suggestion != null &&
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button
type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`}
>
~{fmt(row.amount_suggestion.suggestion)}
</button>
)}
</div>
)}
</TableCell>
@ -1405,6 +1485,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
row={row}
threshold={threshold}
onOpen={() => setPaymentLedgerOpen(true)}
onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined}
/>
</TableCell>
@ -1437,40 +1518,56 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Actions */}
<TableCell className="w-[10%] py-3 text-right">
<div className="flex items-center justify-end gap-1">
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
/>
)}
{/* Quick pay — hidden for skipped bills */}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
title="Payment amount"
/>
{showUpdateNudge ? (
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-1 duration-200">
<span className="text-[10px] text-muted-foreground">Update default?</span>
<Button
size="sm" variant="ghost"
onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
onClick={handleUpdateTemplate}
className="h-6 px-2 text-[10px] font-semibold text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300"
>
Add
{fmt(nudgeAmount)}
</Button>
<button
type="button"
onClick={() => setShowUpdateNudge(false)}
className="text-muted-foreground/40 transition-colors hover:text-muted-foreground"
title="Dismiss"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<>
{hasAutopaySuggestion && (
<AutopaySuggestionActions
row={row}
loading={suggestionLoading}
onConfirm={handleConfirmSuggestion}
onDismiss={handleDismissSuggestion}
/>
)}
{/* Quick pay — hidden for skipped/paid bills */}
{!isPaid && !isSkipped && !hasAutopaySuggestion && (
<div className="flex items-center gap-1">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={summary.remaining || threshold}
className="h-7 w-20 text-right font-mono text-sm bg-background/50 border-border/50"
title="Payment amount"
/>
<Button
size="sm" variant="ghost"
onClick={handleQuickPay}
className="h-7 px-2.5 text-xs font-semibold text-emerald-600 hover:text-emerald-700 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:text-emerald-300 dark:hover:bg-emerald-500/10"
>
Add
</Button>
</div>
)}
</>
)}
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={refresh}
/>
</div>
</TableCell>

View File

@ -0,0 +1,53 @@
'use strict';
/**
* Computes a suggested expected amount for a bill based on the rolling median
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
* (user-corrected values) over raw payment sums.
*/
function computeAmountSuggestion(db, billId, year, month) {
const amounts = [];
let y = year;
let m = month;
for (let i = 0; i < 6; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
const mbs = db.prepare(
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
).get(billId, y, m);
if (mbs?.actual_amount != null) {
amounts.push(mbs.actual_amount);
continue;
}
const result = db.prepare(`
SELECT COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE bill_id = ?
AND deleted_at IS NULL
AND strftime('%Y', paid_date) = ?
AND strftime('%m', paid_date) = ?
`).get(billId, String(y), String(m).padStart(2, '0'));
if (result.total > 0) amounts.push(result.total);
}
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: Math.round(median * 100) / 100,
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
}
module.exports = { computeAmountSuggestion };

View File

@ -4,6 +4,7 @@ const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService');
function validateTrackerMonth(query = {}, now = new Date()) {
const year = parseInt(query.year || now.getFullYear(), 10);
@ -263,6 +264,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.is_skipped = !!(mbs?.is_skipped);
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
return row;
}).filter(Boolean);