feat: add per-group metric strip in report transaction list

Compute Sum/Count/Avg/Min/Max/First/Last per accordion group in the
report view (mirroring backend ReportMetrics.compute_metrics signed
semantics) via new computeTxnMetrics helper and a showMetrics prop on
TransactionList. Remove the now-redundant top-level metric strip from
ReportViewer and restore its Outflows/Inflows/Transactions stat cards.
This commit is contained in:
2026-08-20 16:03:12 +05:30
parent 47c00a06a8
commit f9759e3968
7 changed files with 218 additions and 209 deletions

View File

@@ -110,12 +110,12 @@ export default function Expense() {
<> <>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard <StatCard
label="Total spent" label="Outflows"
value={formatCurrency(summary.totalSpent, summary.currency)} value={formatCurrency(summary.totalSpent, summary.currency)}
hint={`${summary.monthItems.length} transaction${summary.monthItems.length === 1 ? "" : "s"} this month`} hint={`${summary.monthItems.length} transaction${summary.monthItems.length === 1 ? "" : "s"} this month`}
/> />
<StatCard label="This month" value={formatCurrency(summary.monthTotal, summary.currency)} hint={summary.thisMonth} /> <StatCard label="This month" value={formatCurrency(summary.monthTotal, summary.currency)} hint={summary.thisMonth} />
<StatCard label="Income" value={formatCurrency(summary.totalIncome, summary.currency)} /> <StatCard label="Inflows" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box> </Box>
{fieldConfigs && <TransactionList items={sorted} fields={fieldConfigs} />} {fieldConfigs && <TransactionList items={sorted} fields={fieldConfigs} />}

View File

@@ -1,5 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Container, Box, Paper, Typography, Alert } from "@mui/material"; import {
Container,
Box,
Paper,
Typography,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AssessmentIcon from "@mui/icons-material/Assessment"; import AssessmentIcon from "@mui/icons-material/Assessment";
import { useResource, useAppContext, formatCurrency } from "../../react-openapi"; import { useResource, useAppContext, formatCurrency } from "../../react-openapi";
import { useToast } from "../ui/Toast"; import { useToast } from "../ui/Toast";
@@ -103,31 +113,65 @@ export default function Report() {
<GenerateReportPanel onGenerated={handleGenerated} /> <GenerateReportPanel onGenerated={handleGenerated} />
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em", mb: 1.5 }}> <Accordion
Saved reports disableGutters
</Typography> defaultExpanded
sx={{
{reports !== null && reports.length === 0 ? ( border: "1px solid",
<Paper variant="outlined" sx={{ borderRadius: 3 }}> borderColor: "divider",
<EmptyState borderRadius: 2,
icon={<AssessmentIcon />} overflow: "hidden",
title="No reports yet" boxShadow: "none",
description="Generate your first report above — pick a granularity and payee (a payee-only or wildcard config snapshots weekly, monthly and quarterly)." backgroundColor: "background.paper",
/> "&:before": { display: "none" },
</Paper> mb: 4,
) : ( }}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5, mb: 4 }}> >
<ReportList <AccordionSummary
reports={reports ?? []} expandIcon={<ExpandMoreIcon />}
loading={loading} sx={{
fields={reportFields} px: 2.5,
selectedId={selectedId} py: 1,
onView={(id) => setSelectedId(id)} "& .MuiAccordionSummary-content": {
onRegenerate={handleRegenerate} alignItems: "center",
onDelete={handleDelete} gap: 1.5,
/> minWidth: 0,
</Box> },
)} }}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Saved reports
</Typography>
{reports !== null && (
<Typography variant="caption" color="text.secondary">
{reports.length} report{reports.length === 1 ? "" : "s"}
</Typography>
)}
</AccordionSummary>
<AccordionDetails sx={{ px: 2.5, pb: 2.5, pt: 0 }}>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first report above — pick a granularity and payee (a payee-only or wildcard config snapshots weekly, monthly and quarterly)."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
</AccordionDetails>
</Accordion>
{selectedId && ( {selectedId && (
<ReportViewer <ReportViewer

View File

@@ -7,23 +7,16 @@ import {
IconButton, IconButton,
Alert, Alert,
Skeleton, Skeleton,
TextField,
Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material"; } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import CachedIcon from "@mui/icons-material/Cached"; import CachedIcon from "@mui/icons-material/Cached";
import { useAppContext, useResource, formatCurrency } from "../../react-openapi"; import { useResource, formatCurrency } 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 { OptionMultiSelect } from "../common/components/OptionMultiSelect";
import type { TxnFieldConfigs } from "../common/types"; import type { TxnFieldConfigs } from "../common/types";
import { toPeriodGranularity } from "../common/utils/transactions"; import { toPeriodGranularity } from "../common/utils/transactions";
import { aggregateSlice, buildPivot, metricLabels } from "./types"; import { aggregateSlice } from "./types";
interface ReportViewerProps { interface ReportViewerProps {
id: string; id: string;
@@ -34,15 +27,14 @@ interface ReportViewerProps {
} }
export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) { export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) {
const { schemas } = useAppContext();
const { get } = useResource("reports"); const { get } = useResource("reports");
const [report, setReport] = useState<any | null>(null); const [report, setReport] = useState<any | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
const [selectedPeriod, setSelectedPeriod] = useState("*"); const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayee, setSelectedPayee] = useState("*"); const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@@ -52,8 +44,8 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
.then((res) => { .then((res) => {
if (!mounted) return; if (!mounted) return;
setReport(res); setReport(res);
setSelectedPeriod("*"); setSelectedPeriods([]);
setSelectedPayee("*"); setSelectedPayees([]);
}) })
.catch((e: any) => { .catch((e: any) => {
if (!mounted) return; if (!mounted) return;
@@ -79,32 +71,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
}, [report]); }, [report]);
const slice = useMemo( const slice = useMemo(
() => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }), () => aggregateSlice(data ?? [], { periods: selectedPeriods, payees: selectedPayees }),
[data, selectedPeriod, selectedPayee], [data, selectedPeriods, selectedPayees],
); );
const pivot = useMemo(
() => buildPivot(data ?? [], groupOptions.period, groupOptions.payee),
[data, groupOptions],
);
const metrics = useMemo(() => {
const labels = metricLabels(schemas);
const displayKeys = ["sum", "count", "avg", "min", "max", "first_date", "last_date"];
return labels.filter((l) => displayKeys.includes(l.key));
}, [schemas]);
const metricValue = (key: string): string | null => {
if (key === "sum") return formatCurrency(slice.sum, slice.currency);
if (key === "count") return slice.count.toLocaleString("en-IN");
if (key === "avg") return slice.avg == null ? null : formatCurrency(slice.avg, slice.currency);
if (key === "min") return slice.min == null ? null : formatCurrency(slice.min, slice.currency);
if (key === "max") return slice.max == null ? null : formatCurrency(slice.max, slice.currency);
if (key === "first_date") return slice.firstDate;
if (key === "last_date") return slice.lastDate;
return null;
};
if (loading) { if (loading) {
return ( return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}> <Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
@@ -179,23 +149,17 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
) : ( ) : (
<Box sx={{ p: 2.5 }}> <Box sx={{ p: 2.5 }}>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}> <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}>
<Autocomplete <OptionMultiSelect
size="small" label="Period"
sx={{ width: 220 }} options={groupOptions.period}
options={["*", ...groupOptions.period]} value={selectedPeriods}
value={selectedPeriod} onChange={setSelectedPeriods}
onChange={(_, v) => setSelectedPeriod(v ?? "*")}
disabled={groupOptions.period.length === 0}
renderInput={(params) => <TextField {...params} label="Period" />}
/> />
<Autocomplete <OptionMultiSelect
size="small" label="Payee"
sx={{ width: 220 }} options={groupOptions.payee}
options={["*", ...groupOptions.payee]} value={selectedPayees}
value={selectedPayee} onChange={setSelectedPayees}
onChange={(_, v) => setSelectedPayee(v ?? "*")}
disabled={groupOptions.payee.length === 0}
renderInput={(params) => <TextField {...params} label="Payee" />}
/> />
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}> <Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
Showing {slice.count} transactions across {slice.txns.length} rows Showing {slice.count} transactions across {slice.txns.length} rows
@@ -203,78 +167,11 @@ 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="Spent" value={formatCurrency(slice.spent, slice.currency)} color="error.main" /> <StatCard label="Outflows" value={formatCurrency(slice.spent, slice.currency)} color="error.main" />
<StatCard label="Income" value={formatCurrency(slice.income, slice.currency)} color="success.main" /> <StatCard label="Inflows" value={formatCurrency(slice.income, slice.currency)} color="success.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} /> <StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box> </Box>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 3 }}>
{metrics.map((m) => {
const value = metricValue(m.key);
if (value == null) return null;
return (
<Paper key={m.key} variant="outlined" sx={{ px: 1.5, py: 1, borderRadius: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{m.label}
</Typography>
<Typography variant="body2" fontWeight={600}>
{value}
</Typography>
</Paper>
);
})}
</Box>
{selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && (
<TableContainer
component={Paper}
variant="outlined"
sx={{ borderRadius: 2, mb: 3, overflowX: "auto" }}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>Period</TableCell>
{pivot.payees.map((payee) => (
<TableCell key={payee} align="right" sx={{ fontWeight: 700 }}>
{payee}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
Total
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pivot.rows.map((row) => (
<TableRow key={row.period} sx={{ "&:last-child td": { borderBottom: 0 } }}>
<TableCell sx={{ fontWeight: 600 }}>{row.period}</TableCell>
{row.cells.map((cell) => (
<TableCell key={cell.payee} align="right">
{cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(row.periodSum, slice.currency)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>Total</TableCell>
{pivot.totals.map((t) => (
<TableCell key={t.payee} align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(t.sum, slice.currency)}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
)}
{slice.txns.length === 0 ? ( {slice.txns.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3 }}> <Paper variant="outlined" sx={{ borderRadius: 2, p: 3 }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -282,7 +179,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
</Typography> </Typography>
</Paper> </Paper>
) : fields ? ( ) : fields ? (
<TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} /> <TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} showMetrics />
) : null} ) : null}
</Box> </Box>
)} )}

View File

@@ -15,8 +15,8 @@ export interface ReportGroupLike {
} }
export interface SliceFilter { export interface SliceFilter {
period?: string; periods?: string[];
payee?: string; payees?: string[];
} }
export interface SliceSummary { export interface SliceSummary {
@@ -33,25 +33,6 @@ export interface SliceSummary {
currency: string; currency: string;
} }
export interface PivotCell {
payee: string;
sum: number;
count: number;
}
export interface PivotRow {
period: string;
cells: PivotCell[];
periodSum: number;
periodCount: number;
}
export interface PivotTable {
rows: PivotRow[];
payees: string[];
totals: PivotCell[];
}
export interface ReportFieldConfigs { export interface ReportFieldConfigs {
groupLabel: FieldConfig; groupLabel: FieldConfig;
granularity: FieldConfig; granularity: FieldConfig;
@@ -142,11 +123,11 @@ function dateVal(value: string): number {
export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean { export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean {
const key = parseCacheKey(group.key); const key = parseCacheKey(group.key);
if (filter.period && filter.period !== "*") { if (filter.periods && filter.periods.length > 0) {
if (!key.period || key.period.label !== filter.period) return false; if (!key.period || !filter.periods.includes(key.period.label)) return false;
} }
if (filter.payee && filter.payee !== "*") { if (filter.payees && filter.payees.length > 0) {
if (!key.payee || key.payee.label !== filter.payee) return false; if (!key.payee || !filter.payees.includes(key.payee.label)) return false;
} }
return true; return true;
} }
@@ -194,35 +175,6 @@ export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter):
return { sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, spent, income, currency }; return { sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, spent, income, currency };
} }
export function buildPivot(groups: ReportGroupLike[], periodOrder: string[], payeeOrder: string[]): PivotTable {
const payees = payeeOrder.filter((payee) =>
groups.some((g) => parseCacheKey(g.key).payee?.label === payee),
);
const totals = payees.map((payee) => ({ payee, sum: 0, count: 0 }));
const rows: PivotRow[] = [];
for (const period of periodOrder) {
const periodGroups = groups.filter((g) => parseCacheKey(g.key).period?.label === period);
if (periodGroups.length === 0) continue;
const cells = payees.map((payee, i) => {
const cellGroups = periodGroups.filter((g) => parseCacheKey(g.key).payee?.label === payee);
const sum = cellGroups.reduce((s, g) => s + (g.metrics?.sum ?? 0), 0);
const count = cellGroups.reduce((s, g) => s + (g.metrics?.count ?? 0), 0);
totals[i].sum += sum;
totals[i].count += count;
return { payee, sum, count };
});
rows.push({
period,
cells,
periodSum: cells.reduce((s, c) => s + c.sum, 0),
periodCount: cells.reduce((s, c) => s + c.count, 0),
});
}
return { rows, payees, totals };
}
export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null { export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null {
const reportsRes = resources.find((r) => r.name === "reports"); const reportsRes = resources.find((r) => r.name === "reports");
if (!reportsRes) return null; if (!reportsRes) return null;

View File

@@ -0,0 +1,55 @@
import React, { useMemo } from "react";
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
interface OptionMultiSelectProps {
label: string;
options: string[];
value: string[];
onChange: (value: string[]) => void;
}
export function OptionMultiSelect({ label, options, value, onChange }: OptionMultiSelectProps) {
const sortedOptions = useMemo(() => {
const sel = new Set(value);
const picked: string[] = [];
const rest: string[] = [];
for (const opt of options) {
(sel.has(opt) ? picked : rest).push(opt);
}
return [...picked, ...rest];
}, [options, value]);
return (
<Autocomplete
multiple
size="small"
sx={{ width: 260 }}
options={sortedOptions}
value={value}
onChange={(_, newVal) => onChange(newVal)}
disabled={options.length === 0}
renderOption={(props, option, { selected }) => (
<li {...props}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
)}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 12 ? `${tag.slice(0, 10)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={label} placeholder={value.length === 0 ? "All" : undefined} />
)}
/>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
Box, Box,
Typography, Typography,
Paper,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
@@ -14,7 +15,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; 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 { groupByDate, groupByPeriod } from "../utils/transactions"; import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
import type { PeriodGranularity } from "../utils/transactions"; import type { PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow"; import { TransactionRow } from "./TransactionRow";
@@ -22,9 +23,37 @@ interface TransactionListProps {
items: ExpenseItem[]; items: ExpenseItem[];
fields: TxnFieldConfigs; fields: TxnFieldConfigs;
granularity?: PeriodGranularity; granularity?: PeriodGranularity;
showMetrics?: boolean;
} }
export function TransactionList({ items, fields, granularity = "monthly" }: TransactionListProps) { function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: string }) {
const m = computeTxnMetrics(items);
const rows: { label: string; value: string }[] = [
{ label: "Sum", value: formatCurrency(m.sum, currency) },
{ label: "Count", value: m.count.toLocaleString("en-IN") },
{ 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: "First", value: m.firstDate ?? "—" },
{ label: "Last", value: m.lastDate ?? "—" },
];
return (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
{rows.map((row) => (
<Paper key={row.label} variant="outlined" sx={{ px: 1.25, py: 0.5, borderRadius: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{row.label}
</Typography>
<Typography variant="body2" fontWeight={600}>
{row.value}
</Typography>
</Paper>
))}
</Box>
);
}
export function TransactionList({ items, fields, granularity = "monthly", showMetrics = false }: 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);
@@ -160,6 +189,7 @@ export function TransactionList({ items, fields, granularity = "monthly" }: Tran
</Typography> </Typography>
</AccordionSummary> </AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}> <AccordionDetails sx={{ p: 1.5, pt: 1 }}>
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{groupByDate(group.items).map((dateGroup) => ( {groupByDate(group.items).map((dateGroup) => (
<Box <Box

View File

@@ -106,4 +106,35 @@ export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranulari
return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency }; return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency };
}) })
.sort((a, b) => b.key.localeCompare(a.key)); .sort((a, b) => b.key.localeCompare(a.key));
}
export interface TxnMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | 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);
if (amounts.length === 0) {
return { sum: 0, count: 0, avg: null, min: null, max: null, firstDate: null, lastDate: null };
}
const sorted = [...items].sort(
(a, b) => parseOccurredAt(a.occurred_at).getTime() - parseOccurredAt(b.occurred_at).getTime(),
);
const sum = amounts.reduce((s, a) => s + a, 0);
return {
sum,
count: amounts.length,
avg: sum / amounts.length,
min: Math.min(...amounts),
max: Math.max(...amounts),
firstDate: sorted[0]?.occurred_at ?? null,
lastDate: sorted[sorted.length - 1]?.occurred_at ?? null,
};
} }