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