refactor(reports): render API metrics verbatim via period groups
This commit is contained in:
@@ -7,8 +7,8 @@ import type { FieldConfig } from "../../react-openapi";
|
|||||||
import { StatCard } from "../common/components/StatCard";
|
import { StatCard } from "../common/components/StatCard";
|
||||||
import { TransactionList } from "../common/components/TransactionList";
|
import { TransactionList } from "../common/components/TransactionList";
|
||||||
import type { TxnFieldConfigs } from "../common/types";
|
import type { TxnFieldConfigs } from "../common/types";
|
||||||
import { toPeriodGranularity } from "../common/utils/transactions";
|
import type { ListPeriodGroup } from "../common/utils/transactions";
|
||||||
import { aggregateSlice, periodSlices, FLOW_OPTIONS, apiErrorMessage } from "./types";
|
import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
|
||||||
|
|
||||||
const periodField: FieldConfig = {
|
const periodField: FieldConfig = {
|
||||||
name: "period",
|
name: "period",
|
||||||
@@ -143,8 +143,30 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
|||||||
[granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags],
|
[granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const slice = useMemo(() => aggregateSlice(report?.buckets ?? [], filter), [report, filter]);
|
const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]);
|
||||||
const bars = useMemo(() => periodSlices(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) {
|
if (loading && !report) {
|
||||||
return (
|
return (
|
||||||
@@ -172,7 +194,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
|||||||
if (!report) return null;
|
if (!report) return null;
|
||||||
|
|
||||||
const activeGranularity = granularity ?? report.granularities?.[0] ?? "";
|
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 =
|
const range =
|
||||||
report.query?.start_date || report.query?.end_date
|
report.query?.start_date || report.query?.end_date
|
||||||
? `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>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
|
<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="Outflows" value={formatCurrency(slice.outflows, slice.currency)} color="error.main" />
|
||||||
<StatCard label="Inflows" value={formatCurrency(slice.income, slice.currency)} color="success.main" />
|
<StatCard label="Inflows" value={formatCurrency(slice.inflows, slice.currency)} color="success.main" />
|
||||||
<StatCard label="Net" value={formatCurrency(slice.income - slice.spent, slice.currency)} color="info.main" />
|
<StatCard label="Net" value={formatCurrency(slice.inflows - slice.outflows, slice.currency)} color="info.main" />
|
||||||
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
|
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{bars.length === 0 ? (
|
{periodGroups.length === 0 ? (
|
||||||
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
|
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
No data for this slice. Try another granularity, period or payer.
|
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>
|
||||||
) : (
|
) : (
|
||||||
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
|
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
|
||||||
{bars.map((b) => (
|
{periodGroups.map((g) => (
|
||||||
<Box key={b.periodId} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
|
<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" }}>
|
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
|
||||||
{b.periodId}
|
{g.key}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -303,13 +325,13 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
|||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
minWidth: 4,
|
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}>
|
<Typography variant="body2" fontWeight={600}>
|
||||||
{formatCurrency(b.sum, slice.currency)}
|
{formatCurrency(g.metrics.sum, slice.currency)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.disabled">
|
<Typography variant="caption" color="text.disabled">
|
||||||
{b.count} txn{b.count === 1 ? "" : "s"}
|
{g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -317,12 +339,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{slice.txns.length === 0 ? null : fields ? (
|
{slice.txns.length === 0 ? null : fields ? (
|
||||||
<TransactionList
|
<TransactionList fields={fields} groups={listGroups} showMetrics />
|
||||||
items={slice.txns}
|
|
||||||
fields={fields}
|
|
||||||
granularity={toPeriodGranularity(activeGranularity)}
|
|
||||||
showMetrics
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
|
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 {
|
export interface SliceSummary {
|
||||||
|
outflows: number;
|
||||||
|
inflows: number;
|
||||||
sum: number;
|
sum: number;
|
||||||
count: number;
|
count: number;
|
||||||
avg: number | null;
|
avg: number | null;
|
||||||
@@ -9,8 +33,6 @@ export interface SliceSummary {
|
|||||||
firstDate: string | null;
|
firstDate: string | null;
|
||||||
lastDate: string | null;
|
lastDate: string | null;
|
||||||
txns: any[];
|
txns: any[];
|
||||||
spent: number;
|
|
||||||
income: number;
|
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,14 +43,6 @@ export interface SliceFilter {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeriodSlice {
|
|
||||||
periodId: string;
|
|
||||||
sum: number;
|
|
||||||
count: number;
|
|
||||||
firstDate: string | null;
|
|
||||||
lastDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReportFieldConfigs {
|
export interface ReportFieldConfigs {
|
||||||
name: FieldConfig;
|
name: FieldConfig;
|
||||||
generatedAt: FieldConfig;
|
generatedAt: FieldConfig;
|
||||||
@@ -77,78 +91,142 @@ function bucketMatches(bucket: any, filter: SliceFilter): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function periodSlices(buckets: any[], filter: SliceFilter): PeriodSlice[] {
|
function num(v: any): number | null {
|
||||||
const byPeriod = new Map<string, PeriodSlice>();
|
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||||
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()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function aggregateSlice(buckets: any[], filter: SliceFilter): SliceSummary {
|
/**
|
||||||
let sum = 0;
|
* Groups cube periods by their canonical period_id, merging buckets per
|
||||||
let count = 0;
|
* period. The server returns disjoint slices, so additive metric merge is
|
||||||
let spent = 0;
|
* safe; txn ids are deduped defensively. Metrics come verbatim from the API
|
||||||
let income = 0;
|
* except when multiple buckets contribute to one period — then cadence and
|
||||||
let min: number | null = null;
|
* frequency are re-derived from the merged txn dates.
|
||||||
let max: number | null = null;
|
*/
|
||||||
let firstDate: string | null = null;
|
export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPeriodGroup[] {
|
||||||
let lastDate: string | null = null;
|
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";
|
let currency = "INR";
|
||||||
const txns: any[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const bucket of buckets ?? []) {
|
for (const bucket of buckets ?? []) {
|
||||||
if (!bucketMatches(bucket, filter)) continue;
|
if (!bucketMatches(bucket, filter)) continue;
|
||||||
for (const period of bucket.series?.[filter.granularity] ?? []) {
|
for (const period of bucket.series?.[filter.granularity] ?? []) {
|
||||||
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
|
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
|
||||||
const m = period.metrics ?? {};
|
const m = period.metrics ?? {};
|
||||||
if (typeof m.sum === "number") sum += m.sum;
|
let acc = byPeriod.get(period.period_id);
|
||||||
if (typeof m.count === "number") count += m.count;
|
if (!acc) {
|
||||||
if (typeof m.min === "number") min = min === null ? m.min : Math.min(min, m.min);
|
acc = {
|
||||||
if (typeof m.max === "number") max = max === null ? m.max : Math.max(max, m.max);
|
metrics: {
|
||||||
if (m.first_date && (!firstDate || dateVal(String(m.first_date)) < dateVal(firstDate))) {
|
outflows: 0,
|
||||||
firstDate = String(m.first_date);
|
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))) {
|
acc.sources += 1;
|
||||||
lastDate = String(m.last_date);
|
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 ?? []) {
|
for (const txn of period.txns ?? []) {
|
||||||
if (txn?.id != null) {
|
if (txn?.id != null) {
|
||||||
if (seen.has(txn.id)) continue;
|
if (seenTxnIds.has(txn.id)) continue;
|
||||||
seen.add(txn.id);
|
seenTxnIds.add(txn.id);
|
||||||
}
|
}
|
||||||
txns.push(txn);
|
acc.txns.push(txn);
|
||||||
const amt = Number(txn?.amount ?? 0);
|
|
||||||
if (amt < 0) spent += Math.abs(amt);
|
|
||||||
else income += amt;
|
|
||||||
const c = txn?.account?.currency;
|
const c = txn?.account?.currency;
|
||||||
if (c) currency = c;
|
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 {
|
export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null {
|
||||||
|
|||||||
@@ -15,32 +15,70 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
|||||||
import { formatCurrency } from "../../../react-openapi";
|
import { formatCurrency } from "../../../react-openapi";
|
||||||
import type { ExpenseItem, TxnFieldConfigs } from "../types";
|
import type { ExpenseItem, TxnFieldConfigs } from "../types";
|
||||||
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
|
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 { TransactionRow } from "./TransactionRow";
|
||||||
import { StatCard } from "./StatCard";
|
import { StatCard } from "./StatCard";
|
||||||
|
|
||||||
interface TransactionListProps {
|
interface TransactionListProps {
|
||||||
items: ExpenseItem[];
|
items?: ExpenseItem[];
|
||||||
fields: TxnFieldConfigs;
|
fields: TxnFieldConfigs;
|
||||||
granularity?: PeriodGranularity;
|
granularity?: PeriodGranularity;
|
||||||
showMetrics?: boolean;
|
showMetrics?: boolean;
|
||||||
|
groups?: ListPeriodGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: string }) {
|
function cadenceRow(cadenceDays: number | null, frequency: number | null): { label: string; value: string } {
|
||||||
const m = computeTxnMetrics(items);
|
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 }[] = [
|
const rows: { label: string; value: string }[] = [
|
||||||
{ label: "Sum", value: formatCurrency(m.sum, currency) },
|
{ label: "Sum", value: formatCurrency(m.sum, currency) },
|
||||||
{ label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) },
|
{ label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) },
|
||||||
{ label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) },
|
{ label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) },
|
||||||
{ label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) },
|
{ label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) },
|
||||||
{
|
{
|
||||||
label: "Cadence",
|
label: "Count",
|
||||||
value:
|
value:
|
||||||
m.cadenceDays == null
|
typeof m.count === "number"
|
||||||
? "—"
|
? m.count.toLocaleString("en-IN")
|
||||||
: `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`,
|
: 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 (
|
return (
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
|
||||||
{rows.map((row) => (
|
{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 [activeMonth, setActiveMonth] = useState<string | null>(null);
|
||||||
const [openMonth, setOpenMonth] = useState<string | null>(null);
|
const [openMonth, setOpenMonth] = useState<string | null>(null);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | 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 listRef = useRef<HTMLDivElement>(null);
|
||||||
const pillRef = useRef<HTMLDivElement>(null);
|
const pillRef = useRef<HTMLDivElement>(null);
|
||||||
const didInitOpenMonth = useRef(false);
|
const didInitOpenMonth = useRef(false);
|
||||||
@@ -187,7 +234,9 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe
|
|||||||
{formatCurrency(group.income, group.currency)}
|
{formatCurrency(group.income, group.currency)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
|
{showMetrics && (
|
||||||
|
<GroupMetrics items={group.items} currency={group.currency} metrics={group.metrics} />
|
||||||
|
)}
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
|
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
|||||||
@@ -40,10 +40,20 @@ export interface PeriodGroup {
|
|||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toPeriodGranularity(value?: string): PeriodGranularity {
|
/** Metrics verbatim from the API's ReportMetrics (camelCased). */
|
||||||
return value === "weekly" || value === "monthly" || value === "quarterly" || value === "yearly"
|
export interface ListGroupMetrics {
|
||||||
? value
|
sum: number;
|
||||||
: "monthly";
|
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 {
|
function isoWeekKey(d: Date): string {
|
||||||
@@ -115,12 +125,13 @@ export interface TxnMetrics {
|
|||||||
min: number | null;
|
min: number | null;
|
||||||
max: number | null;
|
max: number | null;
|
||||||
cadenceDays: number | null;
|
cadenceDays: number | null;
|
||||||
|
frequency: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */
|
/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */
|
||||||
export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
|
export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
|
||||||
const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number);
|
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) {
|
if (amounts.length === 0) {
|
||||||
return empty;
|
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;
|
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 {
|
return {
|
||||||
sum,
|
sum,
|
||||||
count: amounts.length,
|
count: amounts.length,
|
||||||
@@ -145,5 +157,6 @@ export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
|
|||||||
min: Math.min(...amounts),
|
min: Math.min(...amounts),
|
||||||
max: Math.max(...amounts),
|
max: Math.max(...amounts),
|
||||||
cadenceDays,
|
cadenceDays,
|
||||||
|
frequency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user