refactor(ts): convert Drift + Overdue panels to TSX (B4)

DriftInsightPanel (new @/types DriftBill — its own row shape, money in dollars)
and OverdueCommandCenter (TrackerRow[]). Note: TS flags `Date - Date`
arithmetic → use .getTime(); the overdue/net-delta totals need asDollars at the
fmt boundary. typecheck 0, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:32:39 -05:00
parent c1ec0a4f2e
commit ee31513dc9
3 changed files with 45 additions and 12 deletions

View File

@ -1,13 +1,15 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react'; import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import type { DriftBill } from '@/types';
function DriftRow({ row, refresh }) { function DriftRow({ row, refresh }: { row: DriftBill; refresh: () => void }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleUpdate() { async function handleUpdate() {
@ -131,7 +133,7 @@ function DriftRow({ row, refresh }) {
); );
} }
export default function DriftInsightPanel({ driftBills, refresh }) { export default function DriftInsightPanel({ driftBills, refresh }: { driftBills?: DriftBill[]; refresh: () => void }) {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
if (!driftBills?.length) return null; if (!driftBills?.length) return null;
@ -159,7 +161,7 @@ export default function DriftInsightPanel({ driftBills, refresh }) {
: `${driftBills.length} bills changed price`} : `${driftBills.length} bills changed price`}
</span> </span>
<span className={cn('font-mono text-sm', netColor)}> <span className={cn('font-mono text-sm', netColor)}>
{sign}{fmt(Math.abs(totalNetDelta))}/mo net {sign}{fmt(asDollars(Math.abs(totalNetDelta)))}/mo net
</span> </span>
</div> </div>
<ChevronDown <ChevronDown

View File

@ -1,8 +1,9 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react'; import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, localDateString } from '@/lib/utils'; import { cn, fmt, localDateString } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
@ -12,24 +13,33 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import type { TrackerRow } from '@/types';
function snoozeUntil(days) { function snoozeUntil(days: number): string {
const d = new Date(); const d = new Date();
d.setDate(d.getDate() + days); d.setDate(d.getDate() + days);
return localDateString(d); return localDateString(d);
} }
function daysOverdueLabel(dueDate) { function daysOverdueLabel(dueDate: string | null | undefined): string {
if (!dueDate) return 'overdue'; if (!dueDate) return 'overdue';
const today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const due = new Date(`${dueDate}T00:00:00`); const due = new Date(`${dueDate}T00:00:00`);
const days = Math.floor((today - due) / 86_400_000); const days = Math.floor((today.getTime() - due.getTime()) / 86_400_000);
if (days <= 0) return 'due today'; if (days <= 0) return 'due today';
return `${days} ${days === 1 ? 'day' : 'days'} overdue`; return `${days} ${days === 1 ? 'day' : 'days'} overdue`;
} }
function OverdueRow({ row, year, month, onPayNow, onRefresh }) { interface OverdueRowProps {
row: TrackerRow;
year: number;
month: number;
onPayNow: (row: TrackerRow) => void;
onRefresh: () => void;
}
function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const threshold = row.actual_amount ?? row.expected_amount; const threshold = row.actual_amount ?? row.expected_amount;
@ -51,7 +61,7 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }) {
} }
} }
async function handleSnooze(days) { async function handleSnooze(days: number) {
setLoading(true); setLoading(true);
try { try {
await api.snoozeOverdue(row.id, { await api.snoozeOverdue(row.id, {
@ -141,7 +151,15 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }) {
); );
} }
export default function OverdueCommandCenter({ rows, year, month, refresh, onPayNow }) { interface OverdueCommandCenterProps {
rows: TrackerRow[];
year: number;
month: number;
refresh: () => void;
onPayNow: (row: TrackerRow) => void;
}
export default function OverdueCommandCenter({ rows, year, month, refresh, onPayNow }: OverdueCommandCenterProps) {
const todayStr = localDateString(); const todayStr = localDateString();
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
@ -181,7 +199,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
</span> </span>
{overdueRows.length > 0 && ( {overdueRows.length > 0 && (
<span className="font-mono text-sm text-rose-400 dark:text-rose-300"> <span className="font-mono text-sm text-rose-400 dark:text-rose-300">
{fmt(totalOverdue)} {fmt(asDollars(totalOverdue))}
</span> </span>
)} )}
</div> </div>

View File

@ -14,6 +14,19 @@ import type { Dollars } from '@/lib/money';
export type TrackerStatus = export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
// A price-drift finding (driftService.getDriftReport) — a bill whose recent
// payments trend away from its expected amount.
export interface DriftBill {
id: number;
name: string;
category_name?: string | null;
expected_amount: Dollars;
recent_amount: Dollars;
direction: string;
drift_pct: number;
months_sampled: number;
}
// A suggested autopay payment (trackerService.applyAutopaySuggestions). // A suggested autopay payment (trackerService.applyAutopaySuggestions).
export interface AutopaySuggestion { export interface AutopaySuggestion {
bill_id: number; bill_id: number;