BillTracker/client/lib/trackerUtils.ts

296 lines
10 KiB
TypeScript
Raw Permalink Normal View History

2026-05-31 15:06:10 -05:00
import { todayStr } from '@/lib/utils';
import { asDollars, type Dollars } from '@/lib/money';
import type { TrackerRow, TrackerStatus } from '@/types';
// Re-export the shared domain types so existing `@/lib/trackerUtils` importers
// keep resolving them (the canonical definitions live in `@/types`).
export type { TrackerRow, TrackerStatus } from '@/types';
2026-05-31 15:06:10 -05:00
export const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
2026-05-31 15:06:10 -05:00
];
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: Record<string, string> = {
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/[0.025] dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
autodraft:
'border-l-2 border-l-teal-500/45 bg-teal-500/[0.025] dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
upcoming:
'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
due_soon:
'border-l-2 border-l-amber-400/65 bg-amber-400/[0.055] dark:bg-amber-300/2 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
late: 'border-l-2 border-l-orange-400/80 bg-orange-500/9 ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
missed:
'border-l-2 border-l-rose-400/80 bg-rose-500/10 ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/[0.075] dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
2026-05-31 15:06:10 -05:00
};
export const STATUS_META = {
paid: {
label: 'Paid',
cls: 'bg-emerald-500/15 text-emerald-700 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-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28',
},
late: {
label: 'Late',
cls: 'bg-orange-500/15 text-orange-700 border border-orange-500/45 shadow-sm shadow-orange-950/10 dark:bg-orange-400/20 dark:text-orange-100 dark:border-orange-300/50',
},
missed: {
label: 'Missed',
cls: 'bg-rose-500/15 text-rose-700 border border-rose-500/45 shadow-sm shadow-rose-950/10 dark:bg-rose-400/20 dark:text-rose-100 dark:border-rose-300/50',
},
autodraft: {
label: 'Autodraft',
cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28',
},
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
2026-05-31 15:06:10 -05:00
};
export function paymentDateForTrackerMonth(
year: number,
month: number,
dueDay: number | string | null | undefined,
): string {
2026-05-31 15:06:10 -05:00
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 {
2026-05-31 15:06:10 -05:00
return values
.filter((value) => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap((value) => {
2026-05-31 15:06:10 -05:00
const num = Number(value);
return [String(num), num.toFixed(2), `$${num.toFixed(2)}`];
})
.join(' ');
}
export function rowThreshold(row: TrackerRow): Dollars {
2026-05-31 15:06:10 -05:00
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
}
export function rowEffectiveStatus(row: TrackerRow): TrackerStatus {
2026-05-31 15:06:10 -05:00
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;
2026-05-31 15:06:10 -05:00
}
// 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 {
2026-05-31 15:06:10 -05:00
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return isPaidStatus(status);
2026-05-31 15:06:10 -05:00
}
export function rowIsDebt(row: TrackerRow): boolean {
2026-05-31 15:06:10 -05:00
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),
)
);
2026-05-31 15:06:10 -05:00
}
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[] {
2026-05-31 15:06:10 -05:00
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return next;
2026-05-31 15:06:10 -05:00
next.splice(toIndex, 0, moved);
return next;
}
export function paymentSummary(row: TrackerRow, threshold: Dollars) {
2026-05-31 15:06:10 -05:00
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;
// Re-brand the computed amounts as dollars (arithmetic on branded numbers
// yields a plain number) so callers can pass them straight to fmt/formatUSD.
2026-05-31 15:06:10 -05:00
return {
target: asDollars(target),
paid: asDollars(paid),
paidTowardDue: asDollars(paidTowardDue),
overpaid: asDollars(overpaid),
remaining: asDollars(remaining),
2026-05-31 15:06:10 -05:00
percent,
count: Array.isArray(row.payments) ? row.payments.length : 0,
partial: paid > 0 && remaining > 0,
};
}