BillTracker/client/lib/trackerUtils.ts

270 lines
10 KiB
TypeScript

import { todayStr } from '@/lib/utils';
export const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
export const FILTER_ALL = 'all';
// Sentinel for the "no method" select option — empty string crashes Radix Select
export const METHOD_NONE = 'none';
export const TRACKER_SORT_DEFAULT = 'manual';
export const TRACKER_SORT_ASC = 'asc';
export const TRACKER_SORT_DESC = 'desc';
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
export const TRACKER_SORT_OPTIONS = [
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
{ key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC },
{ key: 'due', label: 'Due date', defaultDir: TRACKER_SORT_ASC },
{ key: 'expected', label: 'Expected amount', defaultDir: TRACKER_SORT_DESC },
{ key: 'previous', label: 'Last month paid', defaultDir: TRACKER_SORT_DESC },
{ key: 'paid', label: 'Paid amount', defaultDir: TRACKER_SORT_DESC },
{ key: 'remaining', label: 'Remaining amount', defaultDir: TRACKER_SORT_DESC },
{ key: 'paid_date', label: 'Paid date', defaultDir: TRACKER_SORT_DESC },
{ key: 'status', label: 'Status', defaultDir: TRACKER_SORT_ASC },
];
export const TRACKER_SORT_LABELS = Object.fromEntries(
TRACKER_SORT_OPTIONS.map(option => [option.key, option.label])
);
export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir])
);
export const ROW_STATUS_CLS = {
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
upcoming: '',
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
};
export const STATUS_META = {
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-500 border border-amber-400/30 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
late: { label: 'Late', cls: 'bg-orange-500/30 text-orange-800 border border-orange-500/60 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
missed: { label: 'Missed', cls: 'bg-rose-500/30 text-rose-800 border border-rose-500/70 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
autodraft: { label: 'Autodraft', cls: 'bg-sky-400/15 text-sky-500 border border-sky-400/30 dark:bg-sky-300/10 dark:text-sky-200 dark:border-sky-300/28' },
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
};
// A tracker month's effective status for a bill.
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
// The subset of a tracker row these helpers read. Rows come from the (untyped)
// tracker API; money fields are dollars (the server serializes cents→dollars).
// Branding them as `Dollars` is a later increment (when the API is typed) — for
// now the computed money fields are plain `number`. Fields the aggregation
// always populates are required; the rest are optional/nullable.
export interface TrackerRow {
name?: string | null;
status: TrackerStatus;
is_skipped?: boolean;
expected_amount: number;
actual_amount?: number | null;
total_paid: number;
paid_toward_due?: number | null;
overpaid_amount?: number | null;
previous_month_paid?: number | null;
autopay_suggestion?: unknown;
category_name?: string | null;
current_balance?: number | null;
minimum_payment?: number | null;
due_date?: string | null;
due_day?: number | null;
last_paid_date?: string | null;
payments?: unknown[];
}
export function paymentDateForTrackerMonth(
year: number,
month: number,
dueDay: number | string | null | undefined,
): string {
const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) {
return todayStr();
}
const daysInMonth = new Date(year, month, 0).getDate();
const day = Number.isInteger(Number(dueDay))
? Math.min(Math.max(Number(dueDay), 1), daysInMonth)
: 1;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
export function amountSearchText(...values: unknown[]): string {
return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => {
const num = Number(value);
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
})
.join(' ');
}
export function rowThreshold(row: TrackerRow): number {
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
}
export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
if (row.is_skipped) return 'skipped';
const threshold = rowThreshold(row);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
}
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
// server's statusService.isPaidStatus — the single low-level check the various
// row/badge/calendar components share.
export function isPaidStatus(status: string): boolean {
return status === 'paid' || status === 'autodraft';
}
export function rowIsPaid(row: TrackerRow): boolean {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return isPaidStatus(status);
}
export function rowIsDebt(row: TrackerRow): boolean {
const category = String(row.category_name || '').toLowerCase();
return Number(row.current_balance) > 0
|| row.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
}
const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
missed: 0,
late: 1,
due_soon: 2,
upcoming: 3,
autodraft: 4,
paid: 5,
skipped: 6,
};
function parseDateSortValue(value: string | null | undefined): number | null {
if (!value) return null;
const parsed = Date.parse(`${value}T00:00:00`);
return Number.isFinite(parsed) ? parsed : null;
}
function numericSortValue(value: unknown): number {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
type SortValue = string | number | null | undefined;
function trackerSortValue(row: TrackerRow, key: string): SortValue {
switch (key) {
case 'name':
return String(row.name || '').toLowerCase();
case 'due':
return parseDateSortValue(row.due_date) ?? numericSortValue(row.due_day);
case 'expected':
return numericSortValue(rowThreshold(row));
case 'previous':
return numericSortValue(row.previous_month_paid);
case 'paid':
return numericSortValue(row.total_paid);
case 'remaining':
return paymentSummary(row, rowThreshold(row)).remaining;
case 'paid_date':
return parseDateSortValue(row.last_paid_date);
case 'status':
return STATUS_SORT_ORDER[rowEffectiveStatus(row)] ?? 99;
default:
return null;
}
}
function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
const aMissing = a === null || a === undefined || a === '';
const bMissing = b === null || b === undefined || b === '';
if (aMissing && bMissing) return 0;
if (aMissing) return 1;
if (bMissing) return -1;
let result = 0;
if (typeof a === 'string' || typeof b === 'string') {
result = String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true });
} else {
result = a === b ? 0 : (a > b ? 1 : -1);
}
return dir === TRACKER_SORT_DESC ? -result : result;
}
export function normalizeTrackerSortKey(key: string): string {
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
}
export function normalizeTrackerSortDir(dir: string): TrackerSortDir {
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
}
export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] {
const key = normalizeTrackerSortKey(sortKey);
if (key === TRACKER_SORT_DEFAULT) return rows;
const dir = normalizeTrackerSortDir(sortDir);
return rows
.map((row, index) => ({ row, index }))
.sort((a, b) => {
const primary = compareSortValues(
trackerSortValue(a.row, key),
trackerSortValue(b.row, key),
dir
);
if (primary !== 0) return primary;
const due = compareSortValues(trackerSortValue(a.row, 'due'), trackerSortValue(b.row, 'due'), TRACKER_SORT_ASC);
if (due !== 0) return due;
const name = compareSortValues(trackerSortValue(a.row, 'name'), trackerSortValue(b.row, 'name'), TRACKER_SORT_ASC);
if (name !== 0) return name;
return a.index - b.index;
})
.map(item => item.row);
}
export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return next;
next.splice(toIndex, 0, moved);
return next;
}
export function paymentSummary(row: TrackerRow, threshold: number) {
const target = Number(threshold) || 0;
const paid = Number(row.total_paid) || 0;
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))
? Number(row.paid_toward_due)
: Math.min(paid, target);
const overpaid = Number.isFinite(Number(row.overpaid_amount))
? Number(row.overpaid_amount)
: Math.max(paid - target, 0);
const remaining = Math.max(target - paidTowardDue, 0);
const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0;
return {
target,
paid,
paidTowardDue,
overpaid,
remaining,
percent,
count: Array.isArray(row.payments) ? row.payments.length : 0,
partial: paid > 0 && remaining > 0,
};
}