feat: redesign per-group stats strip in accordion summary

Replace the dated First/Last pills with a Cadence metric computed from
consecutive transaction dates (mirrors backend ReportMetrics.cadence_days)
and render the group stats as spaced StatCards (Sum/Avg/Min/Max/Cadence)
in the collapsed accordion header, matching the Outflows/Inflows layout.
This commit is contained in:
2026-08-20 17:10:48 +05:30
parent f865f7dec2
commit 79808d6d3e
2 changed files with 28 additions and 24 deletions

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { import {
Box, Box,
Typography, Typography,
Paper,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
@@ -18,6 +17,7 @@ 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 { PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow"; import { TransactionRow } from "./TransactionRow";
import { StatCard } from "./StatCard";
interface TransactionListProps { interface TransactionListProps {
items: ExpenseItem[]; items: ExpenseItem[];
@@ -30,24 +30,21 @@ function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: str
const m = computeTxnMetrics(items); const m = computeTxnMetrics(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: "Count", value: m.count.toLocaleString("en-IN") },
{ 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: "First", value: m.firstDate ?? "—" }, {
{ label: "Last", value: m.lastDate ?? "—" }, label: "Cadence",
value:
m.cadenceDays == null
? "—"
: `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`,
},
]; ];
return ( return (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
{rows.map((row) => ( {rows.map((row) => (
<Paper key={row.label} variant="outlined" sx={{ px: 1.25, py: 0.5, borderRadius: 1.5 }}> <StatCard key={row.label} label={row.label} value={row.value} />
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{row.label}
</Typography>
<Typography variant="body2" fontWeight={600}>
{row.value}
</Typography>
</Paper>
))} ))}
</Box> </Box>
); );
@@ -190,9 +187,7 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe
{formatCurrency(group.income, group.currency)} {formatCurrency(group.income, group.currency)}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "stretch", gap: 1.5, minWidth: 0 }}> {showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
</Box>
</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 }}>

View File

@@ -114,27 +114,36 @@ export interface TxnMetrics {
avg: number | null; avg: number | null;
min: number | null; min: number | null;
max: number | null; max: number | null;
firstDate: string | null; cadenceDays: number | null;
lastDate: string | 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 };
if (amounts.length === 0) { if (amounts.length === 0) {
return { sum: 0, count: 0, avg: null, min: null, max: null, firstDate: null, lastDate: null }; return empty;
} }
const sorted = [...items].sort( const dates = items
(a, b) => parseOccurredAt(a.occurred_at).getTime() - parseOccurredAt(b.occurred_at).getTime(), .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); 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;
}
return { return {
sum, sum,
count: amounts.length, count: amounts.length,
avg: sum / amounts.length, avg: sum / amounts.length,
min: Math.min(...amounts), min: Math.min(...amounts),
max: Math.max(...amounts), max: Math.max(...amounts),
firstDate: sorted[0]?.occurred_at ?? null, cadenceDays,
lastDate: sorted[sorted.length - 1]?.occurred_at ?? null,
}; };
} }