export function formatNumber( value: number, maxDecimals = 2, minDecimals = 0, truncate = false ): string { let display = value; if (truncate) { const factor = 10 ** maxDecimals; display = Math.trunc(display * factor) / factor; } return display.toLocaleString('id-ID', { maximumFractionDigits: maxDecimals, minimumFractionDigits: minDecimals, }); } export function formatRatio(value: number, decimals = 3): string { const factor = 10 ** decimals; const truncated = Math.trunc(value * factor) / factor; return truncated.toFixed(decimals); } export function formatDateId(iso: string | null | undefined): string { if (!iso) return '—'; const date = new Date(`${iso}T00:00:00`); if (Number.isNaN(date.getTime())) return iso; return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' }); } export function formatDateRange(startIso: string, endIso?: string | null): string { if (!endIso) return formatDateId(startIso); return `${formatDateId(startIso)} – ${formatDateId(endIso)}`; } export function todayIso(): string { const now = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; } export function addDaysIso(iso: string, days: number): string { const date = new Date(`${iso}T00:00:00`); date.setDate(date.getDate() + days); const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } export function cycleDayForDate(startDate: string, isoDate: string): number { const start = new Date(`${startDate}T00:00:00`); const current = new Date(`${isoDate}T00:00:00`); const diff = Math.round((current.getTime() - start.getTime()) / 86400000); return Math.max(0, diff); } export function inclusiveDayCount(startIso: string, endIso: string): number { return Math.max(1, cycleDayForDate(startIso, endIso) + 1); } /** Inclusive upper date for open cycles (null end_date) without producing NaN day math. */ export function resolveCycleBoundDate(cycle: { start_date: string; end_date?: string | null; soft_end_date?: string | null; visible_through_date?: string | null; total_days?: number; }): string { if (cycle.end_date) return cycle.end_date; if (cycle.soft_end_date) return cycle.soft_end_date; if (cycle.visible_through_date) return cycle.visible_through_date; if (cycle.total_days != null && cycle.total_days > 0) { return addDaysIso(cycle.start_date, Math.max(cycle.total_days - 1, 0)); } return todayIso(); } /** 0-based last day index from start → bound date (safe when end_date is null). */ export function cycleLastDayIndex(cycle: { start_date: string; end_date?: string | null; soft_end_date?: string | null; visible_through_date?: string | null; total_days?: number; }): number { return cycleDayForDate(cycle.start_date, resolveCycleBoundDate(cycle)); } export type CycleStatusUi = 'active' | 'pending_close' | 'closed'; export function resolveCycleStatus(cycle: { status?: string; end_date?: string | null; visible_through_date?: string; }): CycleStatusUi { if (cycle.status === 'pending_close' || cycle.status === 'closed' || cycle.status === 'active') { return cycle.status; } if ( cycle.end_date && cycle.visible_through_date && cycle.visible_through_date >= cycle.end_date ) { return 'closed'; } return 'active'; }