diff --git a/client/components/tracker/AutopaySuggestionActions.jsx b/client/components/tracker/AutopaySuggestionActions.tsx
similarity index 87%
rename from client/components/tracker/AutopaySuggestionActions.jsx
rename to client/components/tracker/AutopaySuggestionActions.tsx
index 9b88377..03dfb59 100644
--- a/client/components/tracker/AutopaySuggestionActions.jsx
+++ b/client/components/tracker/AutopaySuggestionActions.tsx
@@ -1,8 +1,17 @@
import { Clock, X, CheckCircle2, Loader2 } from 'lucide-react';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
+import type { TrackerRow } from '@/types';
-export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
+interface AutopaySuggestionActionsProps {
+ row: TrackerRow;
+ loading?: boolean;
+ onConfirm?: () => void;
+ onDismiss?: () => void;
+ compact?: boolean;
+}
+
+export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }: AutopaySuggestionActionsProps) {
const suggestion = row.autopay_suggestion;
if (!suggestion) return null;
diff --git a/client/components/tracker/CashFlowCard.jsx b/client/components/tracker/CashFlowCard.tsx
similarity index 91%
rename from client/components/tracker/CashFlowCard.jsx
rename to client/components/tracker/CashFlowCard.tsx
index 889bdff..e719496 100644
--- a/client/components/tracker/CashFlowCard.jsx
+++ b/client/components/tracker/CashFlowCard.tsx
@@ -2,12 +2,14 @@ import { useId } from 'react';
import { motion } from 'framer-motion';
import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react';
import { cn, fmt, fmtDate } from '@/lib/utils';
+import { asDollars } from '@/lib/money';
import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils';
+import type { TrackerCashflow, CashflowTimelinePoint, SafeToSpendUpcoming, TimelineBill } from '@/types';
const CHART_W = 400;
const CHART_H = 96;
-function TimelineChart({ timeline, positive }) {
+function TimelineChart({ timeline, positive }: { timeline: CashflowTimelinePoint[]; positive: boolean }) {
const gradId = useId();
const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H);
if (!geo) return null;
@@ -52,7 +54,7 @@ function TimelineChart({ timeline, positive }) {
{geo.points.filter(p => p.isDrop).map(p => (
- {`${fmtDate(p.date)} — ${p.bills.map(b => `${b.name} ${fmt(b.amount)}`).join(', ')} → ${fmt(p.balance)} left`}
+ {`${fmtDate(p.date)} — ${(p.bills as TimelineBill[]).map(b => `${b.name} ${fmt(b.amount)}`).join(', ')} → ${fmt(asDollars(p.balance))} left`}
))}
@@ -62,7 +64,7 @@ function TimelineChart({ timeline, positive }) {
- {`Payday ${fmtDate(p.date)} — ${fmt(p.balance)} left`}
+ {`Payday ${fmtDate(p.date)} — ${fmt(asDollars(p.balance))} left`}
))}
@@ -70,7 +72,7 @@ function TimelineChart({ timeline, positive }) {
);
}
-function UpcomingList({ upcoming }) {
+function UpcomingList({ upcoming }: { upcoming: SafeToSpendUpcoming[] }) {
const { visible, overflow } = splitUpcoming(upcoming, 4);
if (visible.length === 0) {
@@ -114,7 +116,7 @@ function UpcomingList({ upcoming }) {
* everything still due before the next payday is covered. Sits directly under
* the summary cards and reads from the tracker payload's `cashflow` block.
*/
-export default function CashFlowCard({ cashflow, onSetStartingAmounts }) {
+export default function CashFlowCard({ cashflow, onSetStartingAmounts }: { cashflow?: TrackerCashflow | null; onSetStartingAmounts?: () => void }) {
if (!cashflow) return null;
// No starting amounts and no bank sync — invite setup instead of guessing.
@@ -182,7 +184,7 @@ export default function CashFlowCard({ cashflow, onSetStartingAmounts }) {
)}
title={`${fmt(cashflow.available)} available − ${fmt(cashflow.still_due_total)} still due before payday`}
>
- {positive ? '' : '−'}{fmt(Math.abs(safe))}
+ {positive ? '' : '−'}{fmt(asDollars(Math.abs(safe)))}
diff --git a/client/components/tracker/MonthlyStateDialog.jsx b/client/components/tracker/MonthlyStateDialog.tsx
similarity index 91%
rename from client/components/tracker/MonthlyStateDialog.jsx
rename to client/components/tracker/MonthlyStateDialog.tsx
index 9b0314e..d69bb84 100644
--- a/client/components/tracker/MonthlyStateDialog.jsx
+++ b/client/components/tracker/MonthlyStateDialog.tsx
@@ -1,20 +1,30 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
-import { fmt } from '@/lib/utils';
+import { fmt, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
+import type { TrackerRow } from '@/types';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
-function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
+interface MonthlyStateDialogProps {
+ row: TrackerRow;
+ year: number;
+ month: number;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSaved: () => void;
+}
+
+function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) {
const [actualAmount, setActualAmount] = useState('');
const [notes, setNotes] = useState('');
const [isSkipped, setIsSkipped] = useState(false);
@@ -29,7 +39,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
}
}, [open, row]);
- async function handleSave(e) {
+ async function handleSave(e: React.FormEvent) {
e.preventDefault();
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
if (amt !== null && (isNaN(amt) || amt < 0)) {
@@ -49,7 +59,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
onSaved();
onOpenChange(false);
} catch (err) {
- toast.error(err.message);
+ toast.error(errMessage(err));
} finally {
setSaving(false);
}
diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.tsx
similarity index 93%
rename from client/components/tracker/PaymentLedgerDialog.jsx
rename to client/components/tracker/PaymentLedgerDialog.tsx
index a660356..7391181 100644
--- a/client/components/tracker/PaymentLedgerDialog.jsx
+++ b/client/components/tracker/PaymentLedgerDialog.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
-import { fmt, fmtDate } from '@/lib/utils';
+import { fmt, fmtDate, errMessage } from '@/lib/utils';
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -16,18 +16,30 @@ import {
import PaymentModal from '@/components/tracker/PaymentModal';
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
+import type { Dollars } from '@/lib/money';
+import type { Payment, TrackerRow } from '@/types';
-export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
+interface PaymentLedgerDialogProps {
+ row: TrackerRow;
+ year: number;
+ month: number;
+ threshold: Dollars;
+ defaultPaymentDate: string;
+ onClose: () => void;
+ onSaved?: () => void;
+}
+
+export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }: PaymentLedgerDialogProps) {
const summary = paymentSummary(row, threshold);
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
const [date, setDate] = useState(defaultPaymentDate);
const [method, setMethod] = useState(METHOD_NONE);
const [notes, setNotes] = useState('');
const [busy, setBusy] = useState(false);
- const [editPayment, setEditPayment] = useState(null);
+ const [editPayment, setEditPayment] = useState(null);
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
- async function handleAdd(e) {
+ async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const parsedAmount = parseFloat(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
@@ -52,7 +64,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
onSaved?.();
onClose?.();
} catch (err) {
- toast.error(err.message || 'Failed to add payment');
+ toast.error(errMessage(err, 'Failed to add payment'));
} finally {
setBusy(false);
}
diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.tsx
similarity index 93%
rename from client/components/tracker/PaymentModal.jsx
rename to client/components/tracker/PaymentModal.tsx
index 25e7134..7678bec 100644
--- a/client/components/tracker/PaymentModal.jsx
+++ b/client/components/tracker/PaymentModal.tsx
@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
+import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,10 +15,18 @@ import {
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
+import type { Payment } from '@/types';
const METHOD_NONE = 'none';
-function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
+interface PaymentModalProps {
+ payment: Payment;
+ autopayEnabled?: boolean;
+ onClose: () => void;
+ onSave: () => void;
+}
+
+function PaymentModal({ payment, autopayEnabled, onClose, onSave }: PaymentModalProps) {
const [amount, setAmount] = useState(String(payment.amount));
const [date, setDate] = useState(payment.paid_date);
// Use METHOD_NONE sentinel — empty string value crashes Radix Select
@@ -29,7 +38,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
const showAutopayFailure = autopayEnabled || method === 'autopay';
- async function handleSave(e) {
+ async function handleSave(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
try {
@@ -43,7 +52,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
toast.success('Payment saved');
onSave(); onClose();
} catch (err) {
- toast.error(err.message);
+ toast.error(errMessage(err));
} finally { setBusy(false); }
}
@@ -60,14 +69,14 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
toast.success('Payment restored');
onSave();
} catch (err) {
- toast.error(err.message || 'Failed to restore payment');
+ toast.error(errMessage(err, 'Failed to restore payment'));
}
},
},
});
onSave(); onClose();
} catch (err) {
- toast.error(err.message);
+ toast.error(errMessage(err));
} finally { setBusy(false); }
}
diff --git a/client/components/tracker/StartingAmountsEditDialog.jsx b/client/components/tracker/StartingAmountsEditDialog.tsx
similarity index 87%
rename from client/components/tracker/StartingAmountsEditDialog.jsx
rename to client/components/tracker/StartingAmountsEditDialog.tsx
index 9716e69..fd4a89f 100644
--- a/client/components/tracker/StartingAmountsEditDialog.jsx
+++ b/client/components/tracker/StartingAmountsEditDialog.tsx
@@ -1,7 +1,8 @@
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
-import { fmt } from '@/lib/utils';
+import { fmt, errMessage } from '@/lib/utils';
+import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -14,14 +15,31 @@ const MONTHS = [
'July','August','September','October','November','December',
];
-function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
+interface StartingAmountsPreview {
+ first_amount?: number;
+ fifteenth_amount?: number;
+ other_amount?: number;
+ paid_total?: number;
+ paid_from_first?: number;
+ paid_from_fifteenth?: number;
+}
+
+interface StartingAmountsEditDialogProps {
+ open: boolean;
+ onClose: () => void;
+ year: number;
+ month: number;
+ onSave: () => void;
+}
+
+function StartingAmountsEditDialog({ open, onClose, year, month, onSave }: StartingAmountsEditDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [firstAmount, setFirstAmount] = useState('0');
const [fifteenthAmount, setFifteenthAmount] = useState('0');
const [otherAmount, setOtherAmount] = useState('0');
- const [preview, setPreview] = useState(null);
+ const [preview, setPreview] = useState(null);
const monthName = `${MONTHS[month - 1]} ${year}`;
const localFirst = Number(firstAmount) || 0;
@@ -40,7 +58,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
setLoading(true);
setError('');
try {
- const result = await api.getMonthlyStartingAmounts(year, month);
+ const result = await api.getMonthlyStartingAmounts(year, month) as StartingAmountsPreview;
if (!alive) return;
setPreview(result);
setFirstAmount(String(result.first_amount ?? 0));
@@ -48,7 +66,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
setOtherAmount(String(result.other_amount ?? 0));
} catch (err) {
if (!alive) return;
- setError(err.message || 'Monthly starting amounts could not be loaded.');
+ setError(errMessage(err, 'Monthly starting amounts could not be loaded.'));
} finally {
if (alive) setLoading(false);
}
@@ -57,7 +75,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
return () => { alive = false; };
}, [open, year, month]);
- async function handleSave(e) {
+ async function handleSave(e: React.FormEvent) {
e.preventDefault();
const first = Number(firstAmount);
const fifteenth = Number(fifteenthAmount);
@@ -80,7 +98,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
toast.success('Monthly starting amounts saved.');
onSave();
} catch (err) {
- setError(err.message || 'Monthly starting amounts could not be saved.');
+ setError(errMessage(err, 'Monthly starting amounts could not be saved.'));
} finally {
setSaving(false);
}
@@ -153,29 +171,29 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
Total starting
-
{fmt(totalStarting)}
+
{fmt(asDollars(totalStarting))}
Paid so far
-
{fmt(paidSoFar)}
+
{fmt(asDollars(paidSoFar))}
Total remaining
-
{fmt(totalRemaining)}
+
{fmt(asDollars(totalRemaining))}
1st remaining
- {fmt(firstRemaining)}
+ {fmt(asDollars(firstRemaining))}
15th remaining
- {fmt(fifteenthRemaining)}
+ {fmt(asDollars(fifteenthRemaining))}
Other
- {fmt(localOther)}
+ {fmt(asDollars(localOther))}
diff --git a/client/types.ts b/client/types.ts
index 2416624..2e63693 100644
--- a/client/types.ts
+++ b/client/types.ts
@@ -14,6 +14,14 @@ import type { Dollars } from '@/lib/money';
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
+// A suggested autopay payment (trackerService.applyAutopaySuggestions).
+export interface AutopaySuggestion {
+ bill_id: number;
+ amount: Dollars;
+ paid_date: string;
+ method: string;
+}
+
// A payment record as serialized by the server (money in dollars). serializePayment
// spreads the full DB row, so extra columns pass through as `unknown`.
export interface Payment {
@@ -21,8 +29,10 @@ export interface Payment {
bill_id?: number;
amount: Dollars;
paid_date: string;
+ method?: string | null;
payment_source?: string | null;
notes?: string | null;
+ autopay_failure?: number | null;
balance_delta?: Dollars | null;
interest_delta?: Dollars | null;
created_at?: string;
@@ -153,7 +163,7 @@ export interface TrackerRow {
is_skipped: boolean;
snoozed_until: string | null;
monthly_notes?: string | null;
- autopay_suggestion?: unknown;
+ autopay_suggestion?: AutopaySuggestion | null;
amount_suggestion?: unknown;
previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled):
@@ -206,10 +216,16 @@ export interface SafeToSpendUpcoming {
status: TrackerStatus;
}
+// A bill landing on a given cashflow-timeline day.
+export interface TimelineBill {
+ name: string;
+ amount: Dollars;
+}
+
export interface CashflowTimelinePoint {
date: string;
balance: number; // running dollar balance; cashflowUtils treats it loosely
- bills: unknown[];
+ bills: TimelineBill[];
payday?: boolean;
}