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:
@@ -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 }}>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user