import type { ExpenseItem } from "../types"; import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates"; export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; } export interface DateGroup { date: string; label: string; items: ExpenseItem[]; } export function groupByDate(items: ExpenseItem[]): DateGroup[] { const sorted = [...items].sort( (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), ); const map = new Map(); for (const item of sorted) { const key = item.occurred_at ?? ""; const list = map.get(key) ?? []; list.push(item); map.set(key, list); } return [...map.entries()].map(([date, list]) => ({ date, label: dateLabel(date), items: list, })); } export type PeriodGranularity = "weekly" | "monthly" | "quarterly" | "yearly"; export interface PeriodGroup { key: string; label: string; items: ExpenseItem[]; spent: number; income: number; currency: string; } /** Metrics verbatim from the API's ReportMetrics (camelCased). */ export interface ListGroupMetrics { sum: number; count: number; avg: number | null; min: number | null; max: number | null; cadence: number | null; frequency: number | null; } export interface ListPeriodGroup extends PeriodGroup { metrics?: ListGroupMetrics; } function isoWeekKey(d: Date): string { const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const day = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - day); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; } function quarterKey(d: Date): string { return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`; } export function periodKey(value: string | undefined, granularity: PeriodGranularity): string { const d = parseOccurredAt(value); switch (granularity) { case "weekly": return isoWeekKey(d); case "quarterly": return quarterKey(d); case "yearly": return String(d.getFullYear()); default: return monthKey(value); } } export function periodLabel(key: string, granularity: PeriodGranularity): string { switch (granularity) { case "weekly": case "yearly": return key; case "quarterly": { const [y, q] = key.split("-Q"); return `Q${q} ${y}`; } default: return monthLabel(key); } } export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranularity = "monthly"): PeriodGroup[] { const map = new Map(); for (const item of items) { const key = periodKey(item.occurred_at, granularity); const list = map.get(key) ?? []; list.push(item); map.set(key, list); } return [...map.entries()] .map(([key, list]) => { const sorted = [...list].sort( (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), ); const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency }; }) .sort((a, b) => b.key.localeCompare(a.key)); } export interface TxnMetrics { sum: number; count: number; avg: number | null; min: number | null; max: number | null; cadenceDays: number | null; frequency: number | null; } /** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */ export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics { const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number); const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null, frequency: null }; if (amounts.length === 0) { return empty; } const dates = items .map((it) => it.occurred_at) .filter((d): d is string => !!d) .map((d) => parseOccurredAt(d).getTime()) .sort((a, b) => a - b); const sum = amounts.reduce((s, a) => s + a, 0); let cadenceDays: number | null = null; if (dates.length >= 2) { const gaps: number[] = []; for (let i = 0; i < dates.length - 1; i += 1) { gaps.push((dates[i + 1] - dates[i]) / 86400000); } cadenceDays = Math.round((gaps.reduce((s, g) => s + g, 0) / gaps.length) * 100) / 100; } const frequency = cadenceDays != null && cadenceDays > 0 ? Math.round((1 / cadenceDays) * 100) / 100 : null; return { sum, count: amounts.length, avg: sum / amounts.length, min: Math.min(...amounts), max: Math.max(...amounts), cadenceDays, frequency, }; }