refactor(reports): render API metrics verbatim via period groups

This commit is contained in:
2026-08-21 16:45:47 +05:30
parent e8faafae7d
commit d979e443be
4 changed files with 256 additions and 99 deletions

View File

@@ -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<ListPeriodGroup[]>(
() =>
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
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
<StatCard label="Outflows" value={formatCurrency(slice.spent, slice.currency)} color="error.main" />
<StatCard label="Inflows" value={formatCurrency(slice.income, slice.currency)} color="success.main" />
<StatCard label="Net" value={formatCurrency(slice.income - slice.spent, slice.currency)} color="info.main" />
<StatCard label="Outflows" value={formatCurrency(slice.outflows, slice.currency)} color="error.main" />
<StatCard label="Inflows" value={formatCurrency(slice.inflows, slice.currency)} color="success.main" />
<StatCard label="Net" value={formatCurrency(slice.inflows - slice.outflows, slice.currency)} color="info.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
{bars.length === 0 ? (
{periodGroups.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
@@ -290,10 +312,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
{bars.map((b) => (
<Box key={b.periodId} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
{periodGroups.map((g) => (
<Box key={g.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.periodId}
{g.key}
</Typography>
<Box
sx={{
@@ -303,13 +325,13 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
opacity: 0.85,
minWidth: 4,
}}
style={{ width: `${maxBar ? Math.max((b.sum / maxBar) * 100, 2) : 2}%` }}
style={{ width: `${maxBar ? Math.max((g.metrics.sum / maxBar) * 100, 2) : 2}%` }}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(b.sum, slice.currency)}
{formatCurrency(g.metrics.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{b.count} txn{b.count === 1 ? "" : "s"}
{g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"}
</Typography>
</Box>
))}
@@ -317,12 +339,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
)}
{slice.txns.length === 0 ? null : fields ? (
<TransactionList
items={slice.txns}
fields={fields}
granularity={toPeriodGranularity(activeGranularity)}
showMetrics
/>
<TransactionList fields={fields} groups={listGroups} showMetrics />
) : null}
</Box>
</Paper>

View File

@@ -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<string, PeriodSlice>();
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<string, Acc>();
const seenTxnIds = new Set<string>();
let currency = "INR";
const txns: any[] = [];
const seen = new Set<string>();
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 {

View File

@@ -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 (
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
{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<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const groups = useMemo(() => groupByPeriod(items, granularity), [items, granularity]);
const groups = useMemo<ListPeriodGroup[]>(
() => externalGroups ?? groupByPeriod(items ?? [], granularity),
[externalGroups, items, granularity],
);
const listRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const didInitOpenMonth = useRef(false);
@@ -187,7 +234,9 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe
{formatCurrency(group.income, group.currency)}
</Typography>
</Box>
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
{showMetrics && (
<GroupMetrics items={group.items} currency={group.currency} metrics={group.metrics} />
)}
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>

View File

@@ -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,
};
}