BillTracker/client/lib/trackerUtils.js

225 lines
8.3 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 15:06:10 -05:00
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 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])
);
2026-05-31 15:06:10 -05:00
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' },
};
export function paymentDateForTrackerMonth(year, month, dueDay) {
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) {
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) {
return row.actual_amount != null ? row.actual_amount : row.expected_amount;
}
export function rowEffectiveStatus(row) {
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;
}
export function rowIsPaid(row) {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return status === 'paid' || status === 'autodraft';
}
export function rowIsDebt(row) {
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 = {
missed: 0,
late: 1,
due_soon: 2,
upcoming: 3,
autodraft: 4,
paid: 5,
skipped: 6,
};
function parseDateSortValue(value) {
if (!value) return null;
const parsed = Date.parse(`${value}T00:00:00`);
return Number.isFinite(parsed) ? parsed : null;
}
function numericSortValue(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
function trackerSortValue(row, key) {
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, b, dir) {
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) {
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
}
export function normalizeTrackerSortDir(dir) {
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
}
export function sortTrackerRows(rows, sortKey, sortDir) {
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);
}
2026-05-31 15:06:10 -05:00
export function moveInArray(items, fromIndex, toIndex) {
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
}
export function paymentSummary(row, threshold) {
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,
};
}