import type { Transaction } from "@/types/finance"; export interface CalendarSelection { from: string; to: string } export interface ActivityDay { date: string; inflow: number; outflow: number; count: number } export type FlowDirection = "outflow" | "inflow"; /** Read a calendar date without UTC/local conversion or JavaScript's date rollover. */ export function calendarDate(value: string | null | undefined): string | null { if (!value || !/^\d{4}-\d{2}-\d{2}(?:$|T)/.test(value)) return null; const key = value.slice(0, 10); const date = new Date(`${key}T00:00:00Z`); return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === key ? key : null; } export function dailyCashflow(transactions: Transaction[]): Map { const days = new Map(); for (const t of transactions) { const date = calendarDate(t.date); if (!date || !Number.isFinite(t.amount)) continue; const day = days.get(date) ?? { date, inflow: 0, outflow: 0, count: 0 }; // Accumulate integer cents; direction follows the type, even on imports // whose expense amount is positive. Transfers are cash movement here. day[t.type === "income" ? "inflow" : "outflow"] += Math.round(Math.abs(t.amount) * 100); day.count += 1; days.set(date, day); } for (const day of days.values()) { day.inflow /= 100; day.outflow /= 100; } return days; } export function orderedSelection(a: string, b: string): CalendarSelection { return a <= b ? { from: a, to: b } : { from: b, to: a }; } export function inCalendarSelection(date: string, selection: CalendarSelection): boolean { return date >= selection.from && date <= selection.to; } /** Sunday-first, column-major weeks. Padding cells are not dates/buttons. */ export function calendarWeeks(year: number): (string | null)[][] { const first = new Date(Date.UTC(year, 0, 1)); const last = new Date(Date.UTC(year, 11, 31)); const count = Math.round((last.getTime() - first.getTime()) / 86_400_000) + 1; const size = Math.ceil((first.getUTCDay() + count) / 7) * 7; const cells = Array.from({ length: size }, (_, i) => { const date = new Date(Date.UTC(year, 0, 1 + i - first.getUTCDay())); return date.getUTCFullYear() === year ? date.toISOString().slice(0, 10) : null; }); return Array.from({ length: size / 7 }, (_, i) => cells.slice(i * 7, i * 7 + 7)); } /** Rank-based levels keep a large bill from washing out every everyday purchase. */ export function activityThresholds(days: ReadonlyMap, direction: FlowDirection): number[] { const values = [...days.values()].map(d => d[direction]).filter(n => n > 0).sort((a, b) => a - b); return values.length ? [0.25, 0.5, 0.75].map(p => values[Math.floor((values.length - 1) * p)]) : []; } export function activityLevel(amount: number, thresholds: number[]): number { if (amount <= 0 || !thresholds.length) return 0; return 1 + thresholds.filter(n => amount > n).length; }