diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 8f29d58..25214c7 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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 ( - + title="View payment history" + > +
+ 0 ? 'text-emerald-500' : 'text-muted-foreground')}> + {amountLabel} + + {summary.count > 1 && ( + + {summary.count}× + + )} +
+
+
+
+ + {showQuickFix && ( + + )} +
); } @@ -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 */} - {row.actual_amount != null ? ( + {effectiveActual != null ? ( - {fmt(row.actual_amount)} + {fmt(effectiveActual)} ) : ( - {fmt(row.expected_amount)} +
+ {fmt(row.expected_amount)} + {row.amount_suggestion?.suggestion != null && + Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( + + )} +
)}
@@ -1405,6 +1485,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) { row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} + onMarkFullAmount={!isSkipped ? handleMarkFullAmount : undefined} /> @@ -1437,40 +1518,56 @@ function Row({ row, year, month, refresh, index, onEditBill }) { {/* Actions */}
- {hasAutopaySuggestion && ( - - )} - {/* Quick pay — hidden for skipped bills */} - {!isPaid && !isSkipped && !hasAutopaySuggestion && ( -
- + {showUpdateNudge ? ( +
+ Update default? +
+ ) : ( + <> + {hasAutopaySuggestion && ( + + )} + {/* Quick pay — hidden for skipped/paid bills */} + {!isPaid && !isSkipped && !hasAutopaySuggestion && ( +
+ + +
+ )} + )} - -
diff --git a/services/amountSuggestionService.js b/services/amountSuggestionService.js new file mode 100644 index 0000000..343f781 --- /dev/null +++ b/services/amountSuggestionService.js @@ -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 }; diff --git a/services/trackerService.js b/services/trackerService.js index 2f6c66c..b3b5cad 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -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);