Reports frontend — spec-driven generate form, sliceable viewer, dimension bars #16

Merged
aetos merged 15 commits from cached-reporting into main 2026-08-21 14:16:36 +00:00
2 changed files with 28 additions and 24 deletions
Showing only changes of commit 79808d6d3e - Show all commits

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
@@ -18,6 +17,7 @@ import type { ExpenseItem, TxnFieldConfigs } from "../types";
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
import type { PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow";
import { StatCard } from "./StatCard";
interface TransactionListProps {
items: ExpenseItem[];
@@ -30,24 +30,21 @@ function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: str
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 ?? "—" },
{
label: "Cadence",
value:
m.cadenceDays == null
? "—"
: `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`,
},
];
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) => (
<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>
<StatCard key={row.label} label={row.label} value={row.value} />
))}
</Box>
);
@@ -190,9 +187,7 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe
{formatCurrency(group.income, group.currency)}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "stretch", gap: 1.5, minWidth: 0 }}>
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
</Box>
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>

View File

@@ -114,27 +114,36 @@ export interface TxnMetrics {
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
cadenceDays: 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 };
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(
(a, b) => parseOccurredAt(a.occurred_at).getTime() - parseOccurredAt(b.occurred_at).getTime(),
);
const dates = items
.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);
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 {
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,
cadenceDays,
};
}