107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
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';
|
||
}
|