From d979e443be512d51c9980d16996cccace6b115de Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 16:45:47 +0530 Subject: [PATCH] refactor(reports): render API metrics verbatim via period groups --- src/Reports/ReportViewer.tsx | 59 ++++--- src/Reports/types.ts | 202 +++++++++++++++------- src/common/components/TransactionList.tsx | 71 ++++++-- src/common/utils/transactions.ts | 23 ++- 4 files changed, 256 insertions(+), 99 deletions(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index 72acbe9..a294eff 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -7,8 +7,8 @@ import type { FieldConfig } from "../../react-openapi"; import { StatCard } from "../common/components/StatCard"; import { TransactionList } from "../common/components/TransactionList"; import type { TxnFieldConfigs } from "../common/types"; -import { toPeriodGranularity } from "../common/utils/transactions"; -import { aggregateSlice, periodSlices, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import type { ListPeriodGroup } from "../common/utils/transactions"; +import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; const periodField: FieldConfig = { name: "period", @@ -143,8 +143,30 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re [granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags], ); - const slice = useMemo(() => aggregateSlice(report?.buckets ?? [], filter), [report, filter]); - const bars = useMemo(() => periodSlices(report?.buckets ?? [], filter), [report, filter]); + const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]); + const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]); + const listGroups = useMemo( + () => + periodGroups.map((g) => ({ + key: g.key, + label: g.key, + items: g.txns, + spent: g.metrics.outflows, + income: g.metrics.inflows, + currency: g.currency, + metrics: { + sum: g.metrics.sum, + count: g.metrics.count, + avg: g.metrics.avg, + min: g.metrics.min, + max: g.metrics.max, + cadence: g.metrics.cadence, + frequency: g.metrics.frequency, + txnsPerMonth: g.metrics.txnsPerMonth, + }, + })), + [periodGroups], + ); if (loading && !report) { return ( @@ -172,7 +194,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re if (!report) return null; const activeGranularity = granularity ?? report.granularities?.[0] ?? ""; - const maxBar = bars.reduce((m, b) => Math.max(m, b.sum), 0); + const maxBar = periodGroups.reduce((m, g) => Math.max(m, g.metrics.sum), 0); const range = report.query?.start_date || report.query?.end_date ? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}` @@ -276,13 +298,13 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re - - - + + + - {bars.length === 0 ? ( + {periodGroups.length === 0 ? ( No data for this slice. Try another granularity, period or payer. @@ -290,10 +312,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : ( - {bars.map((b) => ( - + {periodGroups.map((g) => ( + - {b.periodId} + {g.key} - {formatCurrency(b.sum, slice.currency)} + {formatCurrency(g.metrics.sum, slice.currency)} - {b.count} txn{b.count === 1 ? "" : "s"} + {g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"} ))} @@ -317,12 +339,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re )} {slice.txns.length === 0 ? null : fields ? ( - + ) : null} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index c9a990d..fbda26d 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -1,6 +1,30 @@ import type { FieldConfig, ResourceConfig } from "../../react-openapi"; +export interface PeriodMetricsVM { + outflows: number; + inflows: number; + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + firstDate: string | null; + lastDate: string | null; + cadence: number | null; + frequency: number | null; + txnsPerMonth: number | null; +} + +export interface ReportPeriodGroup { + key: string; + metrics: PeriodMetricsVM; + txns: any[]; + currency: string; +} + export interface SliceSummary { + outflows: number; + inflows: number; sum: number; count: number; avg: number | null; @@ -9,8 +33,6 @@ export interface SliceSummary { firstDate: string | null; lastDate: string | null; txns: any[]; - spent: number; - income: number; currency: string; } @@ -21,14 +43,6 @@ export interface SliceFilter { tags?: string[]; } -export interface PeriodSlice { - periodId: string; - sum: number; - count: number; - firstDate: string | null; - lastDate: string | null; -} - export interface ReportFieldConfigs { name: FieldConfig; generatedAt: FieldConfig; @@ -77,78 +91,142 @@ function bucketMatches(bucket: any, filter: SliceFilter): boolean { return true; } -export function periodSlices(buckets: any[], filter: SliceFilter): PeriodSlice[] { - const byPeriod = new Map(); - for (const bucket of buckets ?? []) { - if (!bucketMatches(bucket, filter)) continue; - for (const period of bucket.series?.[filter.granularity] ?? []) { - if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; - const m = period.metrics ?? {}; - const cur = byPeriod.get(period.period_id) ?? { - periodId: period.period_id, - sum: 0, - count: 0, - firstDate: null, - lastDate: null, - }; - cur.sum += typeof m.sum === "number" ? m.sum : 0; - cur.count += typeof m.count === "number" ? m.count : 0; - if (m.first_date && (!cur.firstDate || dateVal(String(m.first_date)) < dateVal(cur.firstDate))) { - cur.firstDate = String(m.first_date); - } - if (m.last_date && (!cur.lastDate || dateVal(String(m.last_date)) > dateVal(cur.lastDate))) { - cur.lastDate = String(m.last_date); - } - byPeriod.set(period.period_id, cur); - } - } - return [...byPeriod.values()]; +function num(v: any): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; } -export function aggregateSlice(buckets: any[], filter: SliceFilter): SliceSummary { - let sum = 0; - let count = 0; - let spent = 0; - let income = 0; - let min: number | null = null; - let max: number | null = null; - let firstDate: string | null = null; - let lastDate: string | null = null; +/** + * Groups cube periods by their canonical period_id, merging buckets per + * period. The server returns disjoint slices, so additive metric merge is + * safe; txn ids are deduped defensively. Metrics come verbatim from the API + * except when multiple buckets contribute to one period — then cadence and + * frequency are re-derived from the merged txn dates. + */ +export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPeriodGroup[] { + interface Acc { + metrics: PeriodMetricsVM; + txns: any[]; + sources: number; + apiCadence: number | null; + apiFrequency: number | null; + apiTxnsPerMonth: number | null; + } + const byPeriod = new Map(); + const seenTxnIds = new Set(); let currency = "INR"; - const txns: any[] = []; - const seen = new Set(); for (const bucket of buckets ?? []) { if (!bucketMatches(bucket, filter)) continue; for (const period of bucket.series?.[filter.granularity] ?? []) { if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; const m = period.metrics ?? {}; - if (typeof m.sum === "number") sum += m.sum; - if (typeof m.count === "number") count += m.count; - if (typeof m.min === "number") min = min === null ? m.min : Math.min(min, m.min); - if (typeof m.max === "number") max = max === null ? m.max : Math.max(max, m.max); - if (m.first_date && (!firstDate || dateVal(String(m.first_date)) < dateVal(firstDate))) { - firstDate = String(m.first_date); + let acc = byPeriod.get(period.period_id); + if (!acc) { + acc = { + metrics: { + outflows: 0, + inflows: 0, + sum: 0, + count: 0, + avg: null, + min: null, + max: null, + firstDate: null, + lastDate: null, + cadence: null, + frequency: null, + txnsPerMonth: null, + }, + txns: [], + sources: 0, + apiCadence: num(m.cadence), + apiFrequency: num(m.frequency), + apiTxnsPerMonth: num(m.txns_per_month), + }; + byPeriod.set(period.period_id, acc); } - if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) { - lastDate = String(m.last_date); + acc.sources += 1; + const vm = acc.metrics; + vm.outflows += m.outflows ?? 0; + vm.inflows += m.inflows ?? 0; + vm.sum += m.sum ?? 0; + vm.count += m.count ?? 0; + const mn = num(m.min); + if (mn != null) vm.min = vm.min == null ? mn : Math.min(vm.min, mn); + const mx = num(m.max); + if (mx != null) vm.max = vm.max == null ? mx : Math.max(vm.max, mx); + if (m.first_date && (!vm.firstDate || dateVal(String(m.first_date)) < dateVal(vm.firstDate))) { + vm.firstDate = String(m.first_date); + } + if (m.last_date && (!vm.lastDate || dateVal(String(m.last_date)) > dateVal(vm.lastDate))) { + vm.lastDate = String(m.last_date); } for (const txn of period.txns ?? []) { if (txn?.id != null) { - if (seen.has(txn.id)) continue; - seen.add(txn.id); + if (seenTxnIds.has(txn.id)) continue; + seenTxnIds.add(txn.id); } - txns.push(txn); - const amt = Number(txn?.amount ?? 0); - if (amt < 0) spent += Math.abs(amt); - else income += amt; + acc.txns.push(txn); const c = txn?.account?.currency; if (c) currency = c; } } } - return { sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, spent, income, currency }; + return [...byPeriod.entries()] + .map(([key, acc]) => { + const vm = acc.metrics; + vm.avg = vm.count ? Math.round((vm.sum / vm.count) * 100) / 100 : null; + if (acc.sources > 1) { + const dates = acc.txns + .map((t) => new Date(t?.occurred_at ?? "").getTime()) + .filter((t) => !Number.isNaN(t)) + .sort((a, b) => a - b); + if (dates.length >= 2) { + let gapSum = 0; + for (let i = 0; i < dates.length - 1; i += 1) gapSum += (dates[i + 1] - dates[i]) / 86400000; + const cadence = Math.round((gapSum / (dates.length - 1)) * 100) / 100; + vm.cadence = cadence > 0 ? cadence : null; + vm.frequency = cadence > 0 ? Math.round((1 / cadence) * 100) / 100 : null; + } else { + vm.cadence = null; + vm.frequency = null; + } + vm.txnsPerMonth = null; + } else { + vm.cadence = acc.apiCadence; + vm.frequency = acc.apiFrequency; + vm.txnsPerMonth = acc.apiTxnsPerMonth; + } + return { key, metrics: vm, txns: acc.txns, currency }; + }) + .sort((a, b) => b.key.localeCompare(a.key)); +} + +export function sliceSummary(groups: ReportPeriodGroup[]): SliceSummary { + const outflows = groups.reduce((s, g) => s + g.metrics.outflows, 0); + const inflows = groups.reduce((s, g) => s + g.metrics.inflows, 0); + const sum = groups.reduce((s, g) => s + g.metrics.sum, 0); + const count = groups.reduce((s, g) => s + g.metrics.count, 0); + let min: number | null = null; + let max: number | null = null; + let firstDate: string | null = null; + let lastDate: string | null = null; + let currency = "INR"; + const txns: any[] = []; + for (const g of groups) { + if (g.metrics.min != null) min = min == null ? g.metrics.min : Math.min(min, g.metrics.min); + if (g.metrics.max != null) max = max == null ? g.metrics.max : Math.max(max, g.metrics.max); + if (g.metrics.firstDate && (!firstDate || dateVal(g.metrics.firstDate) < dateVal(firstDate))) { + firstDate = g.metrics.firstDate; + } + if (g.metrics.lastDate && (!lastDate || dateVal(g.metrics.lastDate) > dateVal(lastDate))) { + lastDate = g.metrics.lastDate; + } + if (g.currency) currency = g.currency; + txns.push(...g.txns); + } + return { outflows, inflows, sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, currency }; } export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null { diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index 80c84b1..072c867 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -15,32 +15,70 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { formatCurrency } from "../../../react-openapi"; import type { ExpenseItem, TxnFieldConfigs } from "../types"; import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions"; -import type { PeriodGranularity } from "../utils/transactions"; +import type { ListGroupMetrics, ListPeriodGroup, PeriodGranularity } from "../utils/transactions"; import { TransactionRow } from "./TransactionRow"; import { StatCard } from "./StatCard"; interface TransactionListProps { - items: ExpenseItem[]; + items?: ExpenseItem[]; fields: TxnFieldConfigs; granularity?: PeriodGranularity; showMetrics?: boolean; + groups?: ListPeriodGroup[]; } -function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: string }) { - const m = computeTxnMetrics(items); +function cadenceRow(cadenceDays: number | null, frequency: number | null): { label: string; value: string } { + const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(2)); + if (cadenceDays != null && cadenceDays < 1) { + return { label: "Frequency", value: `${frequency == null ? "—" : fmt(frequency)} /day` }; + } + return { + label: "Cadence", + value: cadenceDays == null ? "—" : `${fmt(cadenceDays)} days`, + }; +} + +function txnFallbackMetrics(items: ExpenseItem[]): ListGroupMetrics { + const t = computeTxnMetrics(items); + return { + sum: t.sum, + count: t.count, + avg: t.avg, + min: t.min, + max: t.max, + cadence: t.cadenceDays, + frequency: t.frequency, + txnsPerMonth: null, + }; +} + +function GroupMetrics({ + items, + currency, + metrics, +}: { + items: ExpenseItem[]; + currency: string; + metrics?: ListGroupMetrics; +}) { + const m = metrics ?? txnFallbackMetrics(items); const rows: { label: string; value: string }[] = [ { label: "Sum", value: formatCurrency(m.sum, currency) }, { label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) }, { label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) }, { label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) }, { - label: "Cadence", + label: "Count", value: - m.cadenceDays == null - ? "—" - : `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`, + typeof m.count === "number" + ? m.count.toLocaleString("en-IN") + : String(items.length), }, + cadenceRow(m.cadence, m.frequency), ]; + if (m.txnsPerMonth != null) { + rows.push({ label: "Per Month", value: Number.isInteger(m.txnsPerMonth) ? String(m.txnsPerMonth) : m.txnsPerMonth.toFixed(2) }); + } return ( {rows.map((row) => ( @@ -50,11 +88,20 @@ function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: str ); } -export function TransactionList({ items, fields, granularity = "monthly", showMetrics = false }: TransactionListProps) { +export function TransactionList({ + items, + fields, + granularity = "monthly", + showMetrics = false, + groups: externalGroups, +}: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); - const groups = useMemo(() => groupByPeriod(items, granularity), [items, granularity]); + const groups = useMemo( + () => externalGroups ?? groupByPeriod(items ?? [], granularity), + [externalGroups, items, granularity], + ); const listRef = useRef(null); const pillRef = useRef(null); const didInitOpenMonth = useRef(false); @@ -187,7 +234,9 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe {formatCurrency(group.income, group.currency)} - {showMetrics && } + {showMetrics && ( + + )} diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index dd9996e..1ff40da 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -40,10 +40,20 @@ export interface PeriodGroup { currency: string; } -export function toPeriodGranularity(value?: string): PeriodGranularity { - return value === "weekly" || value === "monthly" || value === "quarterly" || value === "yearly" - ? value - : "monthly"; +/** 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; + txnsPerMonth: number | null; +} + +export interface ListPeriodGroup extends PeriodGroup { + metrics?: ListGroupMetrics; } function isoWeekKey(d: Date): string { @@ -115,12 +125,13 @@ export interface TxnMetrics { 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 }; + const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null, frequency: null }; if (amounts.length === 0) { return empty; } @@ -138,6 +149,7 @@ export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics { } 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, @@ -145,5 +157,6 @@ export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics { min: Math.min(...amounts), max: Math.max(...amounts), cadenceDays, + frequency, }; } \ No newline at end of file