2026-05-31 15:06:10 -05:00
|
|
|
import { todayStr } from '@/lib/utils';
|
feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
|
|
|
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 = [
|
2026-07-06 14:23:53 -05:00
|
|
|
'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';
|
|
|
|
|
|
2026-06-07 00:41:07 -05:00
|
|
|
export const TRACKER_SORT_DEFAULT = 'manual';
|
|
|
|
|
export const TRACKER_SORT_ASC = 'asc';
|
|
|
|
|
export const TRACKER_SORT_DESC = 'desc';
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
|
|
|
|
|
|
2026-06-07 00:41:07 -05:00
|
|
|
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(
|
2026-07-06 14:23:53 -05:00
|
|
|
TRACKER_SORT_OPTIONS.map((option) => [option.key, option.label]),
|
2026-06-07 00:41:07 -05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
|
2026-07-06 14:23:53 -05:00
|
|
|
TRACKER_SORT_OPTIONS.map((option) => [option.key, option.defaultDir]),
|
2026-06-07 00:41:07 -05:00
|
|
|
);
|
|
|
|
|
|
2026-07-04 19:36:01 -05:00
|
|
|
export const ROW_STATUS_CLS: Record<string, string> = {
|
2026-07-10 18:03:15 -05:00
|
|
|
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/2.5 dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
|
2026-07-06 14:23:53 -05:00
|
|
|
autodraft:
|
2026-07-10 18:03:15 -05:00
|
|
|
'border-l-2 border-l-teal-500/45 bg-teal-500/2.5 dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
|
2026-07-06 14:23:53 -05:00
|
|
|
upcoming:
|
|
|
|
|
'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
|
|
|
|
|
due_soon:
|
2026-07-10 18:03:15 -05:00
|
|
|
'border-l-2 border-l-amber-400/65 bg-amber-400/5.5 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',
|
2026-07-06 14:23:53 -05:00
|
|
|
missed:
|
2026-07-10 18:03:15 -05:00
|
|
|
'border-l-2 border-l-rose-400/80 bg-rose-500/10 ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/7.5 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 = {
|
2026-07-06 14:23:53 -05:00
|
|
|
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
|
|
|
};
|
|
|
|
|
|
2026-07-03 21:53:45 -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')}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
export function amountSearchText(...values: unknown[]): string {
|
2026-05-31 15:06:10 -05:00
|
|
|
return values
|
2026-07-06 14:23:53 -05:00
|
|
|
.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(' ');
|
|
|
|
|
}
|
|
|
|
|
|
feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
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;
|
2026-07-06 14:23:53 -05:00
|
|
|
return isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft'
|
|
|
|
|
? 'paid'
|
|
|
|
|
: row.status;
|
2026-05-31 15:06:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 18:36:30 -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.
|
2026-07-03 21:53:45 -05:00
|
|
|
export function isPaidStatus(status: string): boolean {
|
2026-07-03 18:36:30 -05:00
|
|
|
return status === 'paid' || status === 'autodraft';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
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;
|
2026-07-03 18:36:30 -05:00
|
|
|
return isPaidStatus(status);
|
2026-05-31 15:06:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
export function rowIsDebt(row: TrackerRow): boolean {
|
2026-05-31 15:06:10 -05:00
|
|
|
const category = String(row.category_name || '').toLowerCase();
|
2026-07-06 14:23:53 -05:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
|
2026-06-07 00:41:07 -05:00
|
|
|
missed: 0,
|
|
|
|
|
late: 1,
|
|
|
|
|
due_soon: 2,
|
|
|
|
|
upcoming: 3,
|
|
|
|
|
autodraft: 4,
|
|
|
|
|
paid: 5,
|
|
|
|
|
skipped: 6,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
function parseDateSortValue(value: string | null | undefined): number | null {
|
2026-06-07 00:41:07 -05:00
|
|
|
if (!value) return null;
|
|
|
|
|
const parsed = Date.parse(`${value}T00:00:00`);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
function numericSortValue(value: unknown): number {
|
2026-06-07 00:41:07 -05:00
|
|
|
const number = Number(value);
|
|
|
|
|
return Number.isFinite(number) ? number : 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
type SortValue = string | number | null | undefined;
|
|
|
|
|
|
|
|
|
|
function trackerSortValue(row: TrackerRow, key: string): SortValue {
|
2026-06-07 00:41:07 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
function compareSortValues(a: SortValue, b: SortValue, dir: string): number {
|
2026-06-07 00:41:07 -05:00
|
|
|
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 {
|
2026-07-06 14:23:53 -05:00
|
|
|
result = a === b ? 0 : a > b ? 1 : -1;
|
2026-06-07 00:41:07 -05:00
|
|
|
}
|
|
|
|
|
return dir === TRACKER_SORT_DESC ? -result : result;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
export function normalizeTrackerSortKey(key: string): string {
|
2026-06-07 00:41:07 -05:00
|
|
|
return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
export function normalizeTrackerSortDir(dir: string): TrackerSortDir {
|
2026-06-07 00:41:07 -05:00
|
|
|
return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
export function sortTrackerRows(
|
|
|
|
|
rows: TrackerRow[],
|
|
|
|
|
sortKey: string,
|
|
|
|
|
sortDir: string,
|
|
|
|
|
): TrackerRow[] {
|
2026-06-07 00:41:07 -05:00
|
|
|
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),
|
2026-07-06 14:23:53 -05:00
|
|
|
dir,
|
2026-06-07 00:41:07 -05:00
|
|
|
);
|
|
|
|
|
if (primary !== 0) return primary;
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const due = compareSortValues(
|
|
|
|
|
trackerSortValue(a.row, 'due'),
|
|
|
|
|
trackerSortValue(b.row, 'due'),
|
|
|
|
|
TRACKER_SORT_ASC,
|
|
|
|
|
);
|
2026-06-07 00:41:07 -05:00
|
|
|
if (due !== 0) return due;
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const name = compareSortValues(
|
|
|
|
|
trackerSortValue(a.row, 'name'),
|
|
|
|
|
trackerSortValue(b.row, 'name'),
|
|
|
|
|
TRACKER_SORT_ASC,
|
|
|
|
|
);
|
2026-06-07 00:41:07 -05:00
|
|
|
if (name !== 0) return name;
|
|
|
|
|
|
|
|
|
|
return a.index - b.index;
|
|
|
|
|
})
|
2026-07-06 14:23:53 -05:00
|
|
|
.map((item) => item.row);
|
2026-06-07 00:41:07 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:53:45 -05:00
|
|
|
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);
|
2026-07-03 21:53:45 -05:00
|
|
|
if (moved === undefined) return next;
|
2026-05-31 15:06:10 -05:00
|
|
|
next.splice(toIndex, 0, moved);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
|
|
|
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;
|
feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
|
|
|
// 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 {
|
feat(ts): type the Tracker API response with branded Dollars (A1)
New client/types.ts domain-types module. Types the full /tracker response —
TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money
field branded `Dollars` (the server serializes cents→dollars, verified against
statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking +
buildSafeToSpend). api.tracker() now returns Promise<TrackerResponse>.
TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from
@/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain
`number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands
its computed amounts via asDollars so callers can fmt() them directly). This is
the first endpoint where the money brand flows end-to-end: fetch layer -> row
helpers, and (in phase B) into the components that format them.
Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/
_stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an
identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build
green, 48 client tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:45 -05:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|