From f08bc72037a0ad8d5febd4254e039e6f02356923 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 19 Aug 2026 22:00:14 +0530 Subject: [PATCH 01/15] reports page: spec-driven generate panel, list, and sliceable viewer Add a /reports page built on react-openapi's runtime configs: - inline generate panel (GroupSpec enum, prefetched payee picker, strict DD-MM-YYYY range, 400 surfacing) - metadata report list with view/regenerate/delete - viewer that slices the cached per-entity payload via metadata.group_options, aggregates metrics + txns, pivots period x payee, and renders month accordions - route + nav entry; no hand-rolled schema duplication --- src/Header.tsx | 1 + src/Reports/GenerateReportPanel.tsx | 210 +++++++++++++ src/Reports/ReportList.tsx | 122 ++++++++ src/Reports/ReportViewer.tsx | 438 ++++++++++++++++++++++++++++ src/Reports/Reports.tsx | 160 ++++++++++ src/Reports/types.ts | 302 +++++++++++++++++++ src/main.jsx | 2 + 7 files changed, 1235 insertions(+) create mode 100644 src/Reports/GenerateReportPanel.tsx create mode 100644 src/Reports/ReportList.tsx create mode 100644 src/Reports/ReportViewer.tsx create mode 100644 src/Reports/Reports.tsx create mode 100644 src/Reports/types.ts diff --git a/src/Header.tsx b/src/Header.tsx index ff15ae9..144255c 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -34,6 +34,7 @@ interface HeaderProps { const NAV_LINKS = [ { label: "Home", path: "/" }, { label: "Expenses", path: "/expenses" }, + { label: "Reports", path: "/reports" }, { label: "Fetch Requests", path: "/fetch-requests" }, ]; diff --git a/src/Reports/GenerateReportPanel.tsx b/src/Reports/GenerateReportPanel.tsx new file mode 100644 index 0000000..9845649 --- /dev/null +++ b/src/Reports/GenerateReportPanel.tsx @@ -0,0 +1,210 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Paper, + Typography, + TextField, + Button, + IconButton, + MenuItem, + Select, + FormControl, + InputLabel, + Autocomplete, + Alert, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; +import { useResource, useAppContext, applyDisplayFormat } from "../../react-openapi"; +import { useToast } from "../ui/Toast"; +import { apiErrorMessage, groupTypeEnum, isDdmmyyyy, periodHints } from "./types"; + +interface GroupRow { + id: number; + group_type: string; + group_value: string; +} + +interface GenerateReportPanelProps { + onGenerated: (reports: any[]) => void; +} + +export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { + const { schemas, resources } = useAppContext(); + const { create } = useResource("reports"); + const { list: listEntities } = useResource("entities"); + const { showToast } = useToast(); + + const [rows, setRows] = useState([{ id: 1, group_type: "monthly", group_value: "*" }]); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [entityNames, setEntityNames] = useState([]); + const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({}); + + const types = useMemo(() => groupTypeEnum(schemas), [schemas]); + const entitiesRes = resources.find((r) => r.name === "entities"); + const entitiesFormat = entitiesRes?.displayFormat ?? "{name}"; + + useEffect(() => { + let mounted = true; + listEntities({ limit: 0 }).then((res) => { + if (!mounted) return; + const names = (res.items ?? []) + .map((it: any) => applyDisplayFormat(it, entitiesFormat)) + .filter((n: string) => n); + setEntityNames([...new Set(names)].sort((a, b) => a.localeCompare(b))); + }); + return () => { + mounted = false; + }; + }, [listEntities, entitiesFormat]); + + const isPayee = (type: string) => type === "payee"; + + const valueOptions = (row: GroupRow): string[] => ["*", ...(isPayee(row.group_type) ? entityNames : [])]; + + const valueHint = (row: GroupRow): string => + isPayee(row.group_type) ? "Entity name, or * for all payees" : `e.g. ${periodHints(row.group_type).join(", ")}, or *`; + + const nextRowId = () => Math.max(0, ...rows.map((r) => r.id)) + 1; + + const addRow = () => setRows((rs) => [...rs, { id: nextRowId(), group_type: "monthly", group_value: "*" }]); + + const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id)); + + const updateRow = (id: number, patch: Partial) => + setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); + + const validateDates = (): boolean => { + const errs: { start?: string; end?: string } = {}; + if (startDate && !isDdmmyyyy(startDate)) errs.start = "Use DD-MM-YYYY"; + if (endDate && !isDdmmyyyy(endDate)) errs.end = "Use DD-MM-YYYY"; + setDateErrors(errs); + return Object.keys(errs).length === 0; + }; + + const handleSubmit = async () => { + if (!validateDates()) return; + setError(null); + const groups = rows + .filter((r) => r.group_type && r.group_value.trim()) + .map(({ group_type, group_value }) => ({ group_type, group_value: group_value.trim() })); + if (groups.length === 0) { + setError("At least one group is required"); + return; + } + setSubmitting(true); + const payload: Record = { groups }; + if (startDate.trim()) payload.start_date = startDate.trim(); + if (endDate.trim()) payload.end_date = endDate.trim(); + try { + const created = await create(payload); + const list = Array.isArray(created) ? created : created ? [created] : []; + showToast(`Generated ${list.length} report${list.length === 1 ? "" : "s"}`); + onGenerated(list); + } catch (e: any) { + setError(apiErrorMessage(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + Generate report + + + Choose the dimensions to snapshot. At least one group is required — with no period dimension the server + snapshots weekly, monthly and quarterly. + + + {error && ( + + {error} + + )} + + + {rows.map((row) => ( + + + Type + + + updateRow(row.id, { group_value: newVal ?? "" })} + renderInput={(params) => ( + + )} + /> + removeRow(row.id)} + sx={{ mt: 0.25 }} + > + + + + ))} + + + + + + setStartDate(e.target.value)} + error={Boolean(dateErrors.start)} + helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} + sx={{ width: 200 }} + /> + setEndDate(e.target.value)} + error={Boolean(dateErrors.end)} + helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} + sx={{ width: 200 }} + /> + + + + + + + ); +} \ No newline at end of file diff --git a/src/Reports/ReportList.tsx b/src/Reports/ReportList.tsx new file mode 100644 index 0000000..3813d5c --- /dev/null +++ b/src/Reports/ReportList.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@mui/material"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import CachedIcon from "@mui/icons-material/Cached"; +import { ListCellRenderer, formatCurrency } from "../../react-openapi"; +import type { ReportFieldConfigs } from "./types"; + +interface ReportListProps { + reports: any[]; + loading: boolean; + fields: ReportFieldConfigs | null; + selectedId: string | null; + onView: (id: string) => void; + onRegenerate: (report: any) => void; + onDelete: (id: string) => void; +} + +export function ReportList({ reports, loading, fields, selectedId, onView, onRegenerate, onDelete }: ReportListProps) { + if (loading && reports.length === 0) { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ); + } + + return ( + + {reports.map((report) => { + const sum = typeof report.metrics?.sum === "number" ? report.metrics.sum : null; + const range = + report.start_date || report.end_date + ? `range ${report.start_date || "…"} → ${report.end_date || "…"}` + : null; + return ( + + + + + {report.group_label} + + + {fields && ( + + )} + + {report.period_label} + + + / + + + {report.payee} + + {range && ( + + {range} + + )} + + + + {report.entity_count ?? 0} entities + + + {report.txn_count ?? 0} txns + + + {report.expense_count ?? 0} expenses + + {fields && ( + + + + )} + + + + + + {sum != null ? formatCurrency(sum, "INR") : "—"} + + + + + + + + onDelete(report.id)}> + + + + + + + ); + })} + + ); +} \ No newline at end of file diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx new file mode 100644 index 0000000..db7c5a0 --- /dev/null +++ b/src/Reports/ReportViewer.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Box, + Paper, + Typography, + Button, + IconButton, + Alert, + Skeleton, + TextField, + Autocomplete, + Accordion, + AccordionSummary, + AccordionDetails, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CloseIcon from "@mui/icons-material/Close"; +import CachedIcon from "@mui/icons-material/Cached"; +import { useAppContext, useResource, ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; +import { groupByMonth } from "../Expense/ExpenseList"; +import type { ExpenseItem, ExpenseFieldConfigs } from "../Expense/types"; +import { monthLabel } from "../Expense/types"; +import type { TxnFieldConfigs } from "./types"; +import { aggregateSlice, buildPivot, metricLabels } from "./types"; + +interface ReportViewerProps { + id: string; + version: number; + fields: TxnFieldConfigs | null; + onClose: () => void; + onRegenerated: (report: any) => void; +} + +function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +const ReportTxnRow = React.memo(function ReportTxnRow({ + item, + currency, + fields, +}: { + item: ExpenseItem; + currency: string; + fields: ExpenseFieldConfigs; +}) { + const itemCurrency = item.account?.currency ?? currency; + + return ( + + + {item.entity?.logo ? ( + + ) : ( + + )} + + + + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} + + + + + + {item.account?.name && ( + + )} + + + + + + ); +}); + +export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) { + const { schemas } = useAppContext(); + const { get } = useResource("reports"); + + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reload, setReload] = useState(0); + const [selectedPeriod, setSelectedPeriod] = useState("*"); + const [selectedPayee, setSelectedPayee] = useState("*"); + const [openMonth, setOpenMonth] = useState(null); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + get(id) + .then((res) => { + if (!mounted) return; + setReport(res); + setSelectedPeriod("*"); + setSelectedPayee("*"); + }) + .catch((e: any) => { + if (!mounted) return; + setError(e?.response?.data?.detail ?? e?.message ?? "Failed to load report"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, [id, version, reload, get]); + + const data = useMemo(() => (Array.isArray(report?.data) ? report.data : null), [report]); + + const groupOptions = useMemo(() => { + const options = report?.metadata?.group_options; + if (!options || typeof options !== "object") return { period: [], payee: [] }; + return { + period: Array.isArray(options.period) ? options.period.map(String) : [], + payee: Array.isArray(options.payee) ? options.payee.map(String) : [], + }; + }, [report]); + + const slice = useMemo( + () => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }), + [data, selectedPeriod, selectedPayee], + ); + + const months = useMemo(() => groupByMonth(slice.txns), [slice.txns]); + + 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) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + if (!report) return null; + + return ( + + + + + {report.group_label} + + + + {report.granularity} · {report.period_label} · {report.payee} + + + {report.entity_count ?? 0} entities · {report.txn_count ?? 0} txns · {report.expense_count ?? 0} expenses + + {report.start_date || report.end_date ? ( + + range {report.start_date || "…"} → {report.end_date || "…"} + + ) : null} + + + + + + + + {data === null ? ( + + + The cached data for this report has expired. Regenerate it to rebuild the snapshot. + + + + ) : ( + + + setSelectedPeriod(v ?? "*")} + disabled={groupOptions.period.length === 0} + renderInput={(params) => } + /> + setSelectedPayee(v ?? "*")} + disabled={groupOptions.payee.length === 0} + renderInput={(params) => } + /> + + Showing {slice.count} transactions across {slice.txns.length} rows + + + + + + + + + + + {metrics.map((m) => { + const value = metricValue(m.key); + if (value == null) return null; + return ( + + + {m.label} + + + {value} + + + ); + })} + + + {selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && ( + + + + + Period + {pivot.payees.map((payee) => ( + + {payee} + + ))} + + Total + + + + + {pivot.rows.map((row) => ( + + {row.period} + {row.cells.map((cell) => ( + + {cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)} + + ))} + + {formatCurrency(row.periodSum, slice.currency)} + + + ))} + + Total + {pivot.totals.map((t) => ( + + {formatCurrency(t.sum, slice.currency)} + + ))} + + {formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)} + + + +
+
+ )} + + {months.length === 0 ? ( + + + No transactions in this slice. + + + ) : ( + + {months.map((group) => ( + setOpenMonth(isExpanded ? group.key : null)} + TransitionProps={{ unmountOnExit: true }} + sx={{ + border: "1px solid", + borderColor: openMonth === group.key ? "primary.main" : "divider", + borderRadius: 2, + overflow: "hidden", + boxShadow: "none", + backgroundColor: "background.paper", + "&:before": { display: "none" }, + "&:hover": { borderColor: "primary.light" }, + }} + > + } + sx={{ + px: 2, + py: 1, + "& .MuiAccordionSummary-content": { alignItems: "center", gap: 1.5, minWidth: 0 }, + }} + > + + {monthLabel(group.key)} + + + {group.items.length} transaction{group.items.length === 1 ? "" : "s"} + + + + {formatCurrency(group.spent, group.currency)} + + + / + + + {formatCurrency(group.income, group.currency)} + + + + + {fields && + group.items.map((item) => ( + + ))} + + + + ))} + + )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Reports/Reports.tsx b/src/Reports/Reports.tsx new file mode 100644 index 0000000..61b3e0e --- /dev/null +++ b/src/Reports/Reports.tsx @@ -0,0 +1,160 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Container, Box, Paper, Typography, Alert } from "@mui/material"; +import AssessmentIcon from "@mui/icons-material/Assessment"; +import { useResource, useAppContext, formatCurrency } from "../../react-openapi"; +import { useToast } from "../ui/Toast"; +import { PageHeader } from "../ui/PageHeader"; +import { EmptyState } from "../ui/EmptyState"; +import { GenerateReportPanel } from "./GenerateReportPanel"; +import { ReportList } from "./ReportList"; +import { ReportViewer } from "./ReportViewer"; +import { apiErrorMessage, buildReportFieldConfigs, buildTxnFieldConfigs } from "./types"; + +function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { + return ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} + +export default function Reports() { + const { resources } = useAppContext(); + const { showToast } = useToast(); + const { list, loading, error } = useResource("reports"); + const { create, remove } = useResource("reports"); + + const [reports, setReports] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [viewerVersion, setViewerVersion] = useState(0); + + const reportFields = useMemo(() => buildReportFieldConfigs(resources), [resources]); + const txnFields = useMemo(() => buildTxnFieldConfigs(resources), [resources]); + + const load = useCallback(async () => { + const res = await list({ limit: 200 }); + setReports(res.items ?? []); + }, [list]); + + useEffect(() => { + load(); + }, [load]); + + const handleGenerated = useCallback( + (created: any[]) => { + load(); + if (created?.[0]?.id) setSelectedId(created[0].id); + }, + [load], + ); + + const handleRegenerate = useCallback( + async (report: any) => { + const payload: Record = { groups: report.groups ?? [] }; + if (report.start_date) payload.start_date = report.start_date; + if (report.end_date) payload.end_date = report.end_date; + try { + await create(payload); + showToast("Report regenerated"); + setSelectedId(report.id); + setViewerVersion((v) => v + 1); + load(); + } catch (e: any) { + showToast(apiErrorMessage(e), "error"); + } + }, + [create, load, showToast], + ); + + const handleDelete = useCallback( + async (id: string) => { + try { + await remove(id); + showToast("Report deleted"); + if (selectedId === id) setSelectedId(null); + load(); + } catch (e: any) { + showToast(apiErrorMessage(e), "error"); + } + }, + [remove, load, selectedId, showToast], + ); + + const summary = useMemo(() => { + const rows = reports ?? []; + const txnCount = rows.reduce((s, r) => s + (r.txn_count ?? 0), 0); + const total = rows.reduce((s, r) => s + (typeof r.metrics?.sum === "number" ? r.metrics.sum : 0), 0); + return { count: rows.length, txnCount, total }; + }, [reports]); + + return ( + + + + {error && ( + + Failed to load reports: {error} + + )} + + + + + + + + + + + Saved reports + + + {reports !== null && reports.length === 0 ? ( + + } + 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)." + /> + + ) : ( + + setSelectedId(id)} + onRegenerate={handleRegenerate} + onDelete={handleDelete} + /> + + )} + + {selectedId && ( + setSelectedId(null)} + onRegenerated={handleRegenerate} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/Reports/types.ts b/src/Reports/types.ts new file mode 100644 index 0000000..1c5760c --- /dev/null +++ b/src/Reports/types.ts @@ -0,0 +1,302 @@ +import type { FieldConfig, ResourceConfig } from "../../react-openapi"; + +const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/; + +export interface ParsedGroupKey { + period?: { granularity: string; label: string }; + payee?: { label: string }; + range?: { start?: string; end?: string }; +} + +export interface ReportGroupLike { + key: string; + group_label?: string; + metrics?: Record; + txns?: any[]; +} + +export interface SliceFilter { + period?: string; + payee?: string; +} + +export interface SliceSummary { + sum: number; + count: number; + avg: number | null; + min: number | null; + max: number | null; + firstDate: string | null; + lastDate: string | null; + txns: any[]; + spent: number; + income: number; + 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 TxnFieldConfigs { + entity: FieldConfig; + amount: FieldConfig; + account: FieldConfig; + occurredAt: FieldConfig; + logo: FieldConfig; + formats: { entity: string; account: string }; +} + +export interface ReportFieldConfigs { + groupLabel: FieldConfig; + granularity: FieldConfig; + periodLabel: FieldConfig; + payee: FieldConfig; + entityCount: FieldConfig; + generatedAt: FieldConfig; +} + +export interface MetricLabel { + key: string; + label: string; + order: number; +} + +/** Split a concrete cache key into its dimension parts, e.g. `period:monthly:2026-Jan|payee:Zepto`. */ +export function parseCacheKey(key: string): ParsedGroupKey { + const out: ParsedGroupKey = {}; + for (const dim of key.split("|")) { + if (!dim) continue; + const [name, ...rest] = dim.split(":"); + if (name === "period" && rest.length >= 2) { + out.period = { granularity: rest[0], label: rest.slice(1).join(":") }; + } else if (name === "payee" && rest.length >= 1) { + out.payee = { label: rest.join(":") }; + } else if (name === "range" && rest.length >= 1) { + out.range = { start: rest[0] || undefined, end: rest[1] || undefined }; + } + } + return out; +} + +export function isDdmmyyyy(value?: string): boolean { + if (!value) return true; + const m = value.match(DDMMYYYY); + if (!m) return false; + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + return !( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ); +} + +export function parseDdmmyyyy(value: string): Date { + const m = value.match(DDMMYYYY); + if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`); + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + if ( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ) { + throw new Error(`Invalid date: ${value}`); + } + return d; +} + +export function apiErrorMessage(e: any): string { + if (e?.response?.data) { + const d = e.response.data; + if (Array.isArray(d)) return d.map((x: any) => x?.msg ?? String(x)).join("; "); + if (typeof d.detail === "string") return d.detail; + if (typeof d.detail?.msg === "string") return d.detail.msg; + if (typeof d === "string") return d; + } + return e?.message ?? "Request failed"; +} + +export function groupTypeEnum(schemas: Record): string[] { + return schemas?.GroupSpec?.properties?.group_type?.enum ?? []; +} + +export function groupValueFk(schemas: Record): { resource?: string; prefetch?: boolean } | null { + const fk = schemas?.GroupSpec?.properties?.group_value?.["x-fk"]; + return fk && typeof fk === "object" ? fk : null; +} + +export function metricLabels(schemas: Record): MetricLabel[] { + const props: Record = schemas?.ReportMetrics?.properties ?? {}; + return Object.entries(props) + .filter(([, p]) => p && typeof p === "object") + .map(([key, p]) => ({ + key, + label: (p as any)["x-label"] ?? key, + order: (p as any)["x-order"] ?? Infinity, + })) + .sort((a, b) => a.order - b.order); +} + +export function periodHints(granularity: string): string[] { + const y = new Date().getFullYear(); + switch (granularity) { + case "weekly": + return [`${y}-W01`, `${y}-W26`]; + case "monthly": + return [`${y}-Jan`, `${y}-Feb`]; + case "quarterly": + return [`${y}-Jan-Mar`, `${y}-Apr-Jun`]; + case "yearly": + return [`${y - 1}`, `${y}`]; + default: + return [`${y}-Jan`, `${y}-W01`, `${y}-Jan-Mar`, `${y}`]; + } +} + +function dateVal(value: string): number { + try { + return parseDdmmyyyy(value).getTime(); + } catch { + return 0; + } +} + +export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean { + const key = parseCacheKey(group.key); + if (filter.period && filter.period !== "*") { + if (!key.period || key.period.label !== filter.period) return false; + } + if (filter.payee && filter.payee !== "*") { + if (!key.payee || key.payee.label !== filter.payee) return false; + } + return true; +} + +export function aggregateSlice(groups: ReportGroupLike[], 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; + let currency = "INR"; + const txns: any[] = []; + const seen = new Set(); + + for (const group of groups) { + if (!groupMatches(group, filter)) continue; + const m = group.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); + } + if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) { + lastDate = String(m.last_date); + } + for (const txn of group.txns ?? []) { + if (txn?.id != null) { + if (seen.has(txn.id)) continue; + seen.add(txn.id); + } + txns.push(txn); + const amt = Number(txn?.amount ?? 0); + if (amt < 0) spent += Math.abs(amt); + else income += amt; + 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 }; +} + +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 buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null { + const expensesRes = resources.find((r) => r.name === "expenses"); + const entitiesRes = resources.find((r) => r.name === "entities"); + const accountsRes = resources.find((r) => r.name === "accounts"); + const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name); + const entity = find(expensesRes, "entity"); + const amount = find(expensesRes, "amount"); + const account = find(expensesRes, "account"); + const occurredAt = find(expensesRes, "occurred_at"); + const logo = find(entitiesRes, "logo"); + if (!entity || !amount || !account || !occurredAt || !logo) return null; + return { + entity, + amount, + account, + occurredAt, + logo, + formats: { + entity: entitiesRes?.displayFormat ?? "{name}", + account: accountsRes?.displayFormat ?? "{name}", + }, + }; +} + +export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null { + const reportsRes = resources.find((r) => r.name === "reports"); + if (!reportsRes) return null; + const find = (name: string) => reportsRes.fields.find((f) => f.name === name); + const groupLabel = find("group_label"); + const granularity = find("granularity"); + const periodLabel = find("period_label"); + const payee = find("payee"); + const entityCount = find("entity_count"); + const generatedAt = find("generated_at"); + if (!groupLabel || !granularity || !periodLabel || !payee || !entityCount || !generatedAt) return null; + return { groupLabel, granularity, periodLabel, payee, entityCount, generatedAt }; +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index ad1d0f1..7c4d8ea 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -18,6 +18,7 @@ import Home from './Home'; import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; import Expense from './Expense/Expense'; +import Reports from './Reports/Reports'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import Header from './Header'; @@ -80,6 +81,7 @@ const routerMapping = [ { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/expenses", component: Expense, headerTitle: "Expenses" }, + { path: "/reports", component: Reports, headerTitle: "Reports" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, ]; -- 2.49.1 From baa9b296cb318d167ea5c11a80f2f254f6e80de7 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 13:29:44 +0530 Subject: [PATCH 02/15] refactor: extract shared expense/report module into src/common Collapse the Expenses page into a single src/Expense.tsx file and rename the Reports page to src/Reports/Report.tsx, extracting their shared transaction-list UI, date/grouping helpers, field configs, and types into an expense/report-agnostic src/common module. Update ReportViewer and GenerateReportPanel to consume the shared components, rewire main.jsx imports, and remove the old src/Expense/ folder and src/Reports/Reports.tsx. --- src/{Expense => }/Expense.tsx | 67 ++----- src/Reports/GenerateReportPanel.tsx | 3 +- src/Reports/{Reports.tsx => Report.tsx} | 24 +-- src/Reports/ReportViewer.tsx | 166 +----------------- src/Reports/types.ts | 66 +------ src/common/components/StatCard.tsx | 26 +++ .../components/TransactionList.tsx} | 120 ++----------- src/common/components/TransactionRow.tsx | 71 ++++++++ src/common/types.ts | 29 +++ .../types.ts => common/utils/dates.ts} | 63 +++---- src/common/utils/fieldConfigs.ts | 26 +++ src/common/utils/transactions.ts | 27 +++ src/main.jsx | 4 +- 13 files changed, 251 insertions(+), 441 deletions(-) rename src/{Expense => }/Expense.tsx (64%) rename src/Reports/{Reports.tsx => Report.tsx} (86%) create mode 100644 src/common/components/StatCard.tsx rename src/{Expense/ExpenseList.tsx => common/components/TransactionList.tsx} (69%) create mode 100644 src/common/components/TransactionRow.tsx create mode 100644 src/common/types.ts rename src/{Expense/types.ts => common/utils/dates.ts} (52%) create mode 100644 src/common/utils/fieldConfigs.ts create mode 100644 src/common/utils/transactions.ts diff --git a/src/Expense/Expense.tsx b/src/Expense.tsx similarity index 64% rename from src/Expense/Expense.tsx rename to src/Expense.tsx index 076f4e3..8953dc7 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense.tsx @@ -10,32 +10,15 @@ import { } from "@mui/material"; import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import { useNavigate } from "react-router-dom"; -import { useResource, useAppContext, formatCurrency } from "../../react-openapi"; -import { PageHeader } from "../ui/PageHeader"; -import { EmptyState } from "../ui/EmptyState"; -import { ExpenseList } from "./ExpenseList"; -import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./types"; - -function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { - return ( - - - {label} - - - {value} - - {hint && ( - - {hint} - - )} - - ); -} +import { useResource, useAppContext, formatCurrency } from "../react-openapi"; +import { PageHeader } from "./ui/PageHeader"; +import { EmptyState } from "./ui/EmptyState"; +import { StatCard } from "./common/components/StatCard"; +import { TransactionList } from "./common/components/TransactionList"; +import { buildTxnFieldConfigs } from "./common/utils/fieldConfigs"; +import { isExpense } from "./common/utils/transactions"; +import { currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./common/utils/dates"; +import type { ExpenseItem } from "./common/types"; export default function Expense() { const navigate = useNavigate(); @@ -43,33 +26,7 @@ export default function Expense() { const { resources } = useAppContext(); const [items, setItems] = useState(null); - const fieldConfigs = useMemo(() => { - if (!resource) return null; - const find = (name: string) => resource.fields.find((f) => f.name === name); - const entity = find("entity"); - const amount = find("amount"); - const account = find("account"); - const tags = find("tags"); - const occurredAt = find("occurred_at"); - const entitiesRes = resources.find((r) => r.name === "entities"); - const accountsRes = resources.find((r) => r.name === "accounts"); - const tagsRes = resources.find((r) => r.name === "tags"); - const logo = entitiesRes?.fields.find((f) => f.name === "logo"); - if (!entity || !amount || !account || !tags || !occurredAt || !logo) return null; - return { - entity, - amount, - account, - tags, - occurredAt, - logo, - formats: { - entity: entitiesRes?.displayFormat ?? "{name}", - account: accountsRes?.displayFormat ?? "{name}", - tags: tagsRes?.displayFormat ?? "{name}", - }, - }; - }, [resource, resources]); + const fieldConfigs = useMemo(() => buildTxnFieldConfigs(resources), [resources]); useEffect(() => { let mounted = true; @@ -80,7 +37,7 @@ export default function Expense() { return () => { mounted = false; }; - }, []); + }, [list]); const sorted = useMemo( () => @@ -161,7 +118,7 @@ export default function Expense() {
- {fieldConfigs && } + {fieldConfigs && } )} diff --git a/src/Reports/GenerateReportPanel.tsx b/src/Reports/GenerateReportPanel.tsx index 9845649..c634d05 100644 --- a/src/Reports/GenerateReportPanel.tsx +++ b/src/Reports/GenerateReportPanel.tsx @@ -17,7 +17,8 @@ import AddIcon from "@mui/icons-material/Add"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import { useResource, useAppContext, applyDisplayFormat } from "../../react-openapi"; import { useToast } from "../ui/Toast"; -import { apiErrorMessage, groupTypeEnum, isDdmmyyyy, periodHints } from "./types"; +import { isDdmmyyyy } from "../common/utils/dates"; +import { apiErrorMessage, groupTypeEnum, periodHints } from "./types"; interface GroupRow { id: number; diff --git a/src/Reports/Reports.tsx b/src/Reports/Report.tsx similarity index 86% rename from src/Reports/Reports.tsx rename to src/Reports/Report.tsx index 61b3e0e..4b044f9 100644 --- a/src/Reports/Reports.tsx +++ b/src/Reports/Report.tsx @@ -5,30 +5,14 @@ import { useResource, useAppContext, formatCurrency } from "../../react-openapi" import { useToast } from "../ui/Toast"; import { PageHeader } from "../ui/PageHeader"; import { EmptyState } from "../ui/EmptyState"; +import { StatCard } from "../common/components/StatCard"; +import { buildTxnFieldConfigs } from "../common/utils/fieldConfigs"; import { GenerateReportPanel } from "./GenerateReportPanel"; import { ReportList } from "./ReportList"; import { ReportViewer } from "./ReportViewer"; -import { apiErrorMessage, buildReportFieldConfigs, buildTxnFieldConfigs } from "./types"; +import { apiErrorMessage, buildReportFieldConfigs } from "./types"; -function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { - return ( - - - {label} - - - {value} - - {hint && ( - - {hint} - - )} - - ); -} - -export default function Reports() { +export default function Report() { const { resources } = useAppContext(); const { showToast } = useToast(); const { list, loading, error } = useResource("reports"); diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index db7c5a0..2ac5066 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -9,9 +9,6 @@ import { Skeleton, TextField, Autocomplete, - Accordion, - AccordionSummary, - AccordionDetails, Table, TableBody, TableCell, @@ -19,14 +16,12 @@ import { TableHead, TableRow, } from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CloseIcon from "@mui/icons-material/Close"; import CachedIcon from "@mui/icons-material/Cached"; -import { useAppContext, useResource, ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; -import { groupByMonth } from "../Expense/ExpenseList"; -import type { ExpenseItem, ExpenseFieldConfigs } from "../Expense/types"; -import { monthLabel } from "../Expense/types"; -import type { TxnFieldConfigs } from "./types"; +import { useAppContext, useResource, formatCurrency } from "../../react-openapi"; +import { StatCard } from "../common/components/StatCard"; +import { TransactionList } from "../common/components/TransactionList"; +import type { TxnFieldConfigs } from "../common/types"; import { aggregateSlice, buildPivot, metricLabels } from "./types"; interface ReportViewerProps { @@ -37,83 +32,6 @@ interface ReportViewerProps { onRegenerated: (report: any) => void; } -function StatCard({ label, value, color }: { label: string; value: string; color?: string }) { - return ( - - - {label} - - - {value} - - - ); -} - -const ReportTxnRow = React.memo(function ReportTxnRow({ - item, - currency, - fields, -}: { - item: ExpenseItem; - currency: string; - fields: ExpenseFieldConfigs; -}) { - const itemCurrency = item.account?.currency ?? currency; - - return ( - - - {item.entity?.logo ? ( - - ) : ( - - )} - - - - {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} - - - - - - {item.account?.name && ( - - )} - - - - - - ); -}); - export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) { const { schemas } = useAppContext(); const { get } = useResource("reports"); @@ -124,7 +42,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const [reload, setReload] = useState(0); const [selectedPeriod, setSelectedPeriod] = useState("*"); const [selectedPayee, setSelectedPayee] = useState("*"); - const [openMonth, setOpenMonth] = useState(null); useEffect(() => { let mounted = true; @@ -165,8 +82,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re [data, selectedPeriod, selectedPayee], ); - const months = useMemo(() => groupByMonth(slice.txns), [slice.txns]); - const pivot = useMemo( () => buildPivot(data ?? [], groupOptions.period, groupOptions.payee), [data, groupOptions], @@ -297,11 +212,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const value = metricValue(m.key); if (value == null) return null; return ( - + {m.label} @@ -363,74 +274,15 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re )} - {months.length === 0 ? ( + {slice.txns.length === 0 ? ( No transactions in this slice. - ) : ( - - {months.map((group) => ( - setOpenMonth(isExpanded ? group.key : null)} - TransitionProps={{ unmountOnExit: true }} - sx={{ - border: "1px solid", - borderColor: openMonth === group.key ? "primary.main" : "divider", - borderRadius: 2, - overflow: "hidden", - boxShadow: "none", - backgroundColor: "background.paper", - "&:before": { display: "none" }, - "&:hover": { borderColor: "primary.light" }, - }} - > - } - sx={{ - px: 2, - py: 1, - "& .MuiAccordionSummary-content": { alignItems: "center", gap: 1.5, minWidth: 0 }, - }} - > - - {monthLabel(group.key)} - - - {group.items.length} transaction{group.items.length === 1 ? "" : "s"} - - - - {formatCurrency(group.spent, group.currency)} - - - / - - - {formatCurrency(group.income, group.currency)} - - - - - {fields && - group.items.map((item) => ( - - ))} - - - - ))} - - )} + ) : fields ? ( + + ) : null} )} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 1c5760c..ebbee8b 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -1,6 +1,5 @@ import type { FieldConfig, ResourceConfig } from "../../react-openapi"; - -const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/; +import { parseDdmmyyyy } from "../common/utils/dates"; export interface ParsedGroupKey { period?: { granularity: string; label: string }; @@ -53,15 +52,6 @@ export interface PivotTable { totals: PivotCell[]; } -export interface TxnFieldConfigs { - entity: FieldConfig; - amount: FieldConfig; - account: FieldConfig; - occurredAt: FieldConfig; - logo: FieldConfig; - formats: { entity: string; account: string }; -} - export interface ReportFieldConfigs { groupLabel: FieldConfig; granularity: FieldConfig; @@ -94,36 +84,6 @@ export function parseCacheKey(key: string): ParsedGroupKey { return out; } -export function isDdmmyyyy(value?: string): boolean { - if (!value) return true; - const m = value.match(DDMMYYYY); - if (!m) return false; - const [, dd, mm, yyyy] = m; - const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); - return !( - Number.isNaN(d.getTime()) || - d.getDate() !== Number(dd) || - d.getMonth() !== Number(mm) - 1 || - d.getFullYear() !== Number(yyyy) - ); -} - -export function parseDdmmyyyy(value: string): Date { - const m = value.match(DDMMYYYY); - if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`); - const [, dd, mm, yyyy] = m; - const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); - if ( - Number.isNaN(d.getTime()) || - d.getDate() !== Number(dd) || - d.getMonth() !== Number(mm) - 1 || - d.getFullYear() !== Number(yyyy) - ) { - throw new Error(`Invalid date: ${value}`); - } - return d; -} - export function apiErrorMessage(e: any): string { if (e?.response?.data) { const d = e.response.data; @@ -263,30 +223,6 @@ export function buildPivot(groups: ReportGroupLike[], periodOrder: string[], pay return { rows, payees, totals }; } -export function buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null { - const expensesRes = resources.find((r) => r.name === "expenses"); - const entitiesRes = resources.find((r) => r.name === "entities"); - const accountsRes = resources.find((r) => r.name === "accounts"); - const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name); - const entity = find(expensesRes, "entity"); - const amount = find(expensesRes, "amount"); - const account = find(expensesRes, "account"); - const occurredAt = find(expensesRes, "occurred_at"); - const logo = find(entitiesRes, "logo"); - if (!entity || !amount || !account || !occurredAt || !logo) return null; - return { - entity, - amount, - account, - occurredAt, - logo, - formats: { - entity: entitiesRes?.displayFormat ?? "{name}", - account: accountsRes?.displayFormat ?? "{name}", - }, - }; -} - export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null { const reportsRes = resources.find((r) => r.name === "reports"); if (!reportsRes) return null; diff --git a/src/common/components/StatCard.tsx b/src/common/components/StatCard.tsx new file mode 100644 index 0000000..3e4cd77 --- /dev/null +++ b/src/common/components/StatCard.tsx @@ -0,0 +1,26 @@ +import { Paper, Typography } from "@mui/material"; + +interface StatCardProps { + label: string; + value: string; + color?: string; + hint?: string; +} + +export function StatCard({ label, value, color, hint }: StatCardProps) { + return ( + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} \ No newline at end of file diff --git a/src/Expense/ExpenseList.tsx b/src/common/components/TransactionList.tsx similarity index 69% rename from src/Expense/ExpenseList.tsx rename to src/common/components/TransactionList.tsx index 5b10561..d62bf0d 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/common/components/TransactionList.tsx @@ -12,116 +12,18 @@ import { import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; -import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; -import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; -import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types"; +import { formatCurrency } from "../../../react-openapi"; +import type { ExpenseItem, TxnFieldConfigs } from "../types"; +import { groupByMonth } from "../utils/transactions"; +import { monthLabel } from "../utils/dates"; +import { TransactionRow } from "./TransactionRow"; -interface GroupedMonth { - key: string; +interface TransactionListProps { items: ExpenseItem[]; - spent: number; - income: number; - currency: string; + fields: TxnFieldConfigs; } -function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { - const map = new Map(); - for (const item of items) { - const key = monthKey(item.occurred_at); - const list = map.get(key) ?? []; - list.push(item); - map.set(key, list); - } - return [...map.entries()] - .map(([key, list]) => { - const sorted = [...list].sort( - (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), - ); - const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; - const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); - const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); - return { key, items: sorted, spent, income, currency }; - }) - .sort((a, b) => b.key.localeCompare(a.key)); -} - -interface ExpenseRowProps { - item: ExpenseItem; - currency: string; - fields: ExpenseFieldConfigs; -} - -const ExpenseRow = React.memo(function ExpenseRow({ item, currency, fields }: ExpenseRowProps) { - const itemCurrency = item.account?.currency ?? currency; - - return ( - - - {item.entity?.logo ? ( - - ) : ( - - )} - - - - {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} - - - - - - {item.account?.name && ( - - )} - - - - - - ); -}); - -interface ExpenseListProps { - items: ExpenseItem[]; - fields: ExpenseFieldConfigs; -} - -export function ExpenseList({ items, fields }: ExpenseListProps) { +export function TransactionList({ items, fields }: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); @@ -259,7 +161,7 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { {group.items.map((item) => ( - ); -} - -export { groupByMonth }; \ No newline at end of file +} \ No newline at end of file diff --git a/src/common/components/TransactionRow.tsx b/src/common/components/TransactionRow.tsx new file mode 100644 index 0000000..cddb2c3 --- /dev/null +++ b/src/common/components/TransactionRow.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { ListCellRenderer, CurrencyField, applyDisplayFormat } from "../../../react-openapi"; +import type { ExpenseItem, TxnFieldConfigs } from "../types"; + +interface TransactionRowProps { + item: ExpenseItem; + currency: string; + fields: TxnFieldConfigs; +} + +export const TransactionRow = React.memo(function TransactionRow({ item, currency, fields }: TransactionRowProps) { + const itemCurrency = item.account?.currency ?? currency; + + return ( + + + {item.entity?.logo ? ( + + ) : ( + + )} + + + + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} + + + + + + {item.account?.name && ( + + )} + + + + + + ); +}); \ No newline at end of file diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..a0e9b81 --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,29 @@ +import type { FieldConfig } from "../../react-openapi"; + +export interface ExpenseItem { + id: string; + entity?: { name?: string; type?: string; logo?: string } | null; + amount: number; + account?: { name?: string; number?: string; type?: string; currency?: string } | null; + tags?: { icon?: string; name?: string }[]; + occurred_at?: string; + created_at?: string; + updated_at?: string; +} + +export interface GroupedMonth { + key: string; + items: ExpenseItem[]; + spent: number; + income: number; + currency: string; +} + +export interface TxnFieldConfigs { + entity: FieldConfig; + amount: FieldConfig; + account: FieldConfig; + occurredAt: FieldConfig; + logo: FieldConfig; + formats: { entity: string; account: string }; +} \ No newline at end of file diff --git a/src/Expense/types.ts b/src/common/utils/dates.ts similarity index 52% rename from src/Expense/types.ts rename to src/common/utils/dates.ts index efe769e..d6b5e83 100644 --- a/src/Expense/types.ts +++ b/src/common/utils/dates.ts @@ -1,36 +1,7 @@ -import type { FieldConfig } from "../../react-openapi"; - -export interface ExpenseItem { - id: string; - entity?: { name?: string; type?: string; logo?: string } | null; - amount: number; - account?: { name?: string; number?: string; type?: string; currency?: string } | null; - tags?: { icon?: string; name?: string }[]; - occurred_at?: string; - created_at?: string; - updated_at?: string; -} - -export interface ExpenseFieldConfigs { - entity: FieldConfig; - amount: FieldConfig; - account: FieldConfig; - tags: FieldConfig; - occurredAt: FieldConfig; - logo: FieldConfig; - formats: { - entity: string; - account: string; - tags: string; - }; -} - -export function isExpense(item: ExpenseItem): boolean { - return (item.amount ?? 0) < 0; -} +const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/; export function parseOccurredAt(value?: string): Date { - const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/); + const m = value?.match(DDMMYYYY); if (!m) { throw new Error(`Expense occurred_at is not DD-MM-YYYY: ${value}`); } @@ -47,6 +18,36 @@ export function parseOccurredAt(value?: string): Date { return d; } +export function isDdmmyyyy(value?: string): boolean { + if (!value) return true; + const m = value.match(DDMMYYYY); + if (!m) return false; + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + return !( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ); +} + +export function parseDdmmyyyy(value: string): Date { + const m = value.match(DDMMYYYY); + if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`); + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + if ( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ) { + throw new Error(`Invalid date: ${value}`); + } + return d; +} + export function monthKey(value?: string): string { const d = parseOccurredAt(value); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; diff --git a/src/common/utils/fieldConfigs.ts b/src/common/utils/fieldConfigs.ts new file mode 100644 index 0000000..0522faf --- /dev/null +++ b/src/common/utils/fieldConfigs.ts @@ -0,0 +1,26 @@ +import type { ResourceConfig } from "../../../react-openapi"; +import type { TxnFieldConfigs } from "../types"; + +export function buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null { + const expensesRes = resources.find((r) => r.name === "expenses"); + const entitiesRes = resources.find((r) => r.name === "entities"); + const accountsRes = resources.find((r) => r.name === "accounts"); + const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name); + const entity = find(expensesRes, "entity"); + const amount = find(expensesRes, "amount"); + const account = find(expensesRes, "account"); + const occurredAt = find(expensesRes, "occurred_at"); + const logo = find(entitiesRes, "logo"); + if (!entity || !amount || !account || !occurredAt || !logo) return null; + return { + entity, + amount, + account, + occurredAt, + logo, + formats: { + entity: entitiesRes?.displayFormat ?? "{name}", + account: accountsRes?.displayFormat ?? "{name}", + }, + }; +} \ No newline at end of file diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts new file mode 100644 index 0000000..9538088 --- /dev/null +++ b/src/common/utils/transactions.ts @@ -0,0 +1,27 @@ +import type { ExpenseItem, GroupedMonth } from "../types"; +import { monthKey, parseOccurredAt } from "./dates"; + +export function isExpense(item: ExpenseItem): boolean { + return (item.amount ?? 0) < 0; +} + +export function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { + const map = new Map(); + for (const item of items) { + const key = monthKey(item.occurred_at); + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return [...map.entries()] + .map(([key, list]) => { + const sorted = [...list].sort( + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), + ); + const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; + const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); + const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); + return { key, items: sorted, spent, income, currency }; + }) + .sort((a, b) => b.key.localeCompare(a.key)); +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 7c4d8ea..b5fe503 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -17,8 +17,8 @@ import { import Home from './Home'; import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; -import Expense from './Expense/Expense'; -import Reports from './Reports/Reports'; +import Expense from './Expense'; +import Reports from './Reports/Report'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import Header from './Header'; -- 2.49.1 From a107029b9048e40a76189f611a01ab6f149a31b2 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 14:14:44 +0530 Subject: [PATCH 03/15] feat: group transactions by date inside month accordions Add two-layer grouping to the shared transaction list: each month accordion now renders one card per occurred_at date (most recent first) with a date label and txn count header, nesting the usual transaction rows inside. Add dateLabel + groupByDate helpers to src/common and use them in TransactionList. Month accordions, scroll pill/menu, and month totals are unchanged. --- src/common/components/TransactionList.tsx | 51 +++++++++++++++++++---- src/common/utils/dates.ts | 5 +++ src/common/utils/transactions.ts | 26 +++++++++++- 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index d62bf0d..fc4eccd 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -14,7 +14,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { formatCurrency } from "../../../react-openapi"; import type { ExpenseItem, TxnFieldConfigs } from "../types"; -import { groupByMonth } from "../utils/transactions"; +import { groupByDate, groupByMonth } from "../utils/transactions"; import { monthLabel } from "../utils/dates"; import { TransactionRow } from "./TransactionRow"; @@ -160,13 +160,48 @@ export function TransactionList({ items, fields }: TransactionListProps) { - {group.items.map((item) => ( - + {groupByDate(group.items).map((dateGroup) => ( + + + + {dateGroup.label} + + + + {dateGroup.items.length} transaction{dateGroup.items.length === 1 ? "" : "s"} + + + + {dateGroup.items.map((item) => ( + + ))} + + ))} diff --git a/src/common/utils/dates.ts b/src/common/utils/dates.ts index d6b5e83..5758b3d 100644 --- a/src/common/utils/dates.ts +++ b/src/common/utils/dates.ts @@ -63,4 +63,9 @@ export function monthLabel(key: string): string { if (!y || !m) return key; const d = new Date(y, m - 1, 1); return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); +} + +export function dateLabel(value: string): string { + const d = parseOccurredAt(value); + return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); } \ No newline at end of file diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index 9538088..6da2894 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -1,10 +1,34 @@ import type { ExpenseItem, GroupedMonth } from "../types"; -import { monthKey, parseOccurredAt } from "./dates"; +import { dateLabel, monthKey, parseOccurredAt } from "./dates"; export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; } +export interface DateGroup { + date: string; + label: string; + items: ExpenseItem[]; +} + +export function groupByDate(items: ExpenseItem[]): DateGroup[] { + const sorted = [...items].sort( + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), + ); + const map = new Map(); + for (const item of sorted) { + const key = item.occurred_at ?? ""; + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return [...map.entries()].map(([date, list]) => ({ + date, + label: dateLabel(date), + items: list, + })); +} + export function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { const map = new Map(); for (const item of items) { -- 2.49.1 From 47c00a06a88d51d60e3cf51b8327b51f19006148 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 14:37:52 +0530 Subject: [PATCH 04/15] feat: group report transactions by report granularity Make the outer accordion grouping in TransactionList granularity-aware instead of hardcoded months. TransactionList accepts a granularity prop (weekly/monthly/quarterly/yearly, default monthly); ReportViewer passes the report's granularity so a weekly report buckets transactions by ISO week, quarterly by quarter, yearly by year. Expense page keeps monthly grouping. Add periodKey/periodLabel/groupByPeriod/toPeriodGranularity to src/common/utils/transactions.ts and drop the dead groupByMonth/ GroupedMonth helpers. --- src/Reports/ReportViewer.tsx | 3 +- src/common/components/TransactionList.tsx | 15 ++--- src/common/types.ts | 8 --- src/common/utils/transactions.ts | 68 +++++++++++++++++++++-- 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index 2ac5066..a12edc6 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -22,6 +22,7 @@ import { useAppContext, useResource, formatCurrency } 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, buildPivot, metricLabels } from "./types"; interface ReportViewerProps { @@ -281,7 +282,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : fields ? ( - + ) : null} )} diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index fc4eccd..c9e0b38 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -14,20 +14,21 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { formatCurrency } from "../../../react-openapi"; import type { ExpenseItem, TxnFieldConfigs } from "../types"; -import { groupByDate, groupByMonth } from "../utils/transactions"; -import { monthLabel } from "../utils/dates"; +import { groupByDate, groupByPeriod } from "../utils/transactions"; +import type { PeriodGranularity } from "../utils/transactions"; import { TransactionRow } from "./TransactionRow"; interface TransactionListProps { items: ExpenseItem[]; fields: TxnFieldConfigs; + granularity?: PeriodGranularity; } -export function TransactionList({ items, fields }: TransactionListProps) { +export function TransactionList({ items, fields, granularity = "monthly" }: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); - const groups = useMemo(() => groupByMonth(items), [items]); + const groups = useMemo(() => groupByPeriod(items, granularity), [items, granularity]); const listRef = useRef(null); const pillRef = useRef(null); const didInitOpenMonth = useRef(false); @@ -142,7 +143,7 @@ export function TransactionList({ items, fields }: TransactionListProps) { }} > - {monthLabel(group.key)} + {group.label} {group.items.length} transaction{group.items.length === 1 ? "" : "s"} @@ -256,7 +257,7 @@ export function TransactionList({ items, fields }: TransactionListProps) { }} > - {activeMonth ? monthLabel(activeMonth) : ""} + {activeMonth ? (groups.find((g) => g.key === activeMonth)?.label ?? activeMonth) : ""} @@ -276,7 +277,7 @@ export function TransactionList({ items, fields }: TransactionListProps) { onClick={() => handleSelectMonth(group.key)} > diff --git a/src/common/types.ts b/src/common/types.ts index a0e9b81..5b53198 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -11,14 +11,6 @@ export interface ExpenseItem { updated_at?: string; } -export interface GroupedMonth { - key: string; - items: ExpenseItem[]; - spent: number; - income: number; - currency: string; -} - export interface TxnFieldConfigs { entity: FieldConfig; amount: FieldConfig; diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index 6da2894..ecc2a2b 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -1,5 +1,5 @@ -import type { ExpenseItem, GroupedMonth } from "../types"; -import { dateLabel, monthKey, parseOccurredAt } from "./dates"; +import type { ExpenseItem } from "../types"; +import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates"; export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; @@ -29,10 +29,68 @@ export function groupByDate(items: ExpenseItem[]): DateGroup[] { })); } -export function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { +export type PeriodGranularity = "weekly" | "monthly" | "quarterly" | "yearly"; + +export interface PeriodGroup { + key: string; + label: string; + items: ExpenseItem[]; + spent: number; + income: number; + currency: string; +} + +export function toPeriodGranularity(value?: string): PeriodGranularity { + return value === "weekly" || value === "monthly" || value === "quarterly" || value === "yearly" + ? value + : "monthly"; +} + +function isoWeekKey(d: Date): string { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function quarterKey(d: Date): string { + return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`; +} + +export function periodKey(value: string | undefined, granularity: PeriodGranularity): string { + const d = parseOccurredAt(value); + switch (granularity) { + case "weekly": + return isoWeekKey(d); + case "quarterly": + return quarterKey(d); + case "yearly": + return String(d.getFullYear()); + default: + return monthKey(value); + } +} + +export function periodLabel(key: string, granularity: PeriodGranularity): string { + switch (granularity) { + case "weekly": + case "yearly": + return key; + case "quarterly": { + const [y, q] = key.split("-Q"); + return `Q${q} ${y}`; + } + default: + return monthLabel(key); + } +} + +export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranularity = "monthly"): PeriodGroup[] { const map = new Map(); for (const item of items) { - const key = monthKey(item.occurred_at); + const key = periodKey(item.occurred_at, granularity); const list = map.get(key) ?? []; list.push(item); map.set(key, list); @@ -45,7 +103,7 @@ export function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); - return { key, items: sorted, spent, income, currency }; + return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency }; }) .sort((a, b) => b.key.localeCompare(a.key)); } \ No newline at end of file -- 2.49.1 From f9759e3968e1be72b65b3e2af9d043c22ce15e06 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 16:03:12 +0530 Subject: [PATCH 05/15] 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. --- src/Expense.tsx | 4 +- src/Reports/Report.tsx | 96 +++++++++---- src/Reports/ReportViewer.tsx | 147 +++----------------- src/Reports/types.ts | 60 +------- src/common/components/OptionMultiSelect.tsx | 55 ++++++++ src/common/components/TransactionList.tsx | 34 ++++- src/common/utils/transactions.ts | 31 +++++ 7 files changed, 218 insertions(+), 209 deletions(-) create mode 100644 src/common/components/OptionMultiSelect.tsx diff --git a/src/Expense.tsx b/src/Expense.tsx index 8953dc7..29e7093 100644 --- a/src/Expense.tsx +++ b/src/Expense.tsx @@ -110,12 +110,12 @@ export default function Expense() { <> - + {fieldConfigs && } diff --git a/src/Reports/Report.tsx b/src/Reports/Report.tsx index 4b044f9..d8426b0 100644 --- a/src/Reports/Report.tsx +++ b/src/Reports/Report.tsx @@ -1,5 +1,15 @@ 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 { useResource, useAppContext, formatCurrency } from "../../react-openapi"; import { useToast } from "../ui/Toast"; @@ -103,31 +113,65 @@ export default function Report() { - - Saved reports - - - {reports !== null && reports.length === 0 ? ( - - } - 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)." - /> - - ) : ( - - setSelectedId(id)} - onRegenerate={handleRegenerate} - onDelete={handleDelete} - /> - - )} + + } + sx={{ + px: 2.5, + py: 1, + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 1.5, + minWidth: 0, + }, + }} + > + + Saved reports + + {reports !== null && ( + + {reports.length} report{reports.length === 1 ? "" : "s"} + + )} + + + {reports !== null && reports.length === 0 ? ( + + } + 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)." + /> + + ) : ( + + setSelectedId(id)} + onRegenerate={handleRegenerate} + onDelete={handleDelete} + /> + + )} + + {selectedId && ( (null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reload, setReload] = useState(0); - const [selectedPeriod, setSelectedPeriod] = useState("*"); - const [selectedPayee, setSelectedPayee] = useState("*"); + const [selectedPeriods, setSelectedPeriods] = useState([]); + const [selectedPayees, setSelectedPayees] = useState([]); useEffect(() => { let mounted = true; @@ -52,8 +44,8 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re .then((res) => { if (!mounted) return; setReport(res); - setSelectedPeriod("*"); - setSelectedPayee("*"); + setSelectedPeriods([]); + setSelectedPayees([]); }) .catch((e: any) => { if (!mounted) return; @@ -79,32 +71,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re }, [report]); const slice = useMemo( - () => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }), - [data, selectedPeriod, selectedPayee], + () => aggregateSlice(data ?? [], { periods: selectedPeriods, payees: selectedPayees }), + [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) { return ( @@ -179,23 +149,17 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : ( - setSelectedPeriod(v ?? "*")} - disabled={groupOptions.period.length === 0} - renderInput={(params) => } + - setSelectedPayee(v ?? "*")} - disabled={groupOptions.payee.length === 0} - renderInput={(params) => } + Showing {slice.count} transactions across {slice.txns.length} rows @@ -203,78 +167,11 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re - - + + - - {metrics.map((m) => { - const value = metricValue(m.key); - if (value == null) return null; - return ( - - - {m.label} - - - {value} - - - ); - })} - - - {selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && ( - - - - - Period - {pivot.payees.map((payee) => ( - - {payee} - - ))} - - Total - - - - - {pivot.rows.map((row) => ( - - {row.period} - {row.cells.map((cell) => ( - - {cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)} - - ))} - - {formatCurrency(row.periodSum, slice.currency)} - - - ))} - - Total - {pivot.totals.map((t) => ( - - {formatCurrency(t.sum, slice.currency)} - - ))} - - {formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)} - - - -
-
- )} - {slice.txns.length === 0 ? ( @@ -282,7 +179,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : fields ? ( - + ) : null}
)} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index ebbee8b..c9e918e 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -15,8 +15,8 @@ export interface ReportGroupLike { } export interface SliceFilter { - period?: string; - payee?: string; + periods?: string[]; + payees?: string[]; } export interface SliceSummary { @@ -33,25 +33,6 @@ export interface SliceSummary { 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 { groupLabel: FieldConfig; granularity: FieldConfig; @@ -142,11 +123,11 @@ function dateVal(value: string): number { export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean { const key = parseCacheKey(group.key); - if (filter.period && filter.period !== "*") { - if (!key.period || key.period.label !== filter.period) return false; + if (filter.periods && filter.periods.length > 0) { + if (!key.period || !filter.periods.includes(key.period.label)) return false; } - if (filter.payee && filter.payee !== "*") { - if (!key.payee || key.payee.label !== filter.payee) return false; + if (filter.payees && filter.payees.length > 0) { + if (!key.payee || !filter.payees.includes(key.payee.label)) return false; } 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 }; } -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 { const reportsRes = resources.find((r) => r.name === "reports"); if (!reportsRes) return null; diff --git a/src/common/components/OptionMultiSelect.tsx b/src/common/components/OptionMultiSelect.tsx new file mode 100644 index 0000000..0a4d23b --- /dev/null +++ b/src/common/components/OptionMultiSelect.tsx @@ -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 ( + onChange(newVal)} + disabled={options.length === 0} + renderOption={(props, option, { selected }) => ( +
  • + {selected ? : } + {option} +
  • + )} + renderTags={(tagValue, getTagProps) => { + const maxChips = 1; + return ( + <> + {tagValue.slice(0, maxChips).map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return 12 ? `${tag.slice(0, 10)}..` : tag} size="small" />; + })} + {tagValue.length > maxChips && } + + ); + }} + renderInput={(params) => ( + + )} + /> + ); +} \ No newline at end of file diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index c9e0b38..b6898a1 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Box, Typography, + Paper, Accordion, AccordionSummary, AccordionDetails, @@ -14,7 +15,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import { formatCurrency } from "../../../react-openapi"; 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 { TransactionRow } from "./TransactionRow"; @@ -22,9 +23,37 @@ interface TransactionListProps { items: ExpenseItem[]; fields: TxnFieldConfigs; 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 ( + + {rows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + + ); +} + +export function TransactionList({ items, fields, granularity = "monthly", showMetrics = false }: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); @@ -160,6 +189,7 @@ export function TransactionList({ items, fields, granularity = "monthly" }: Tran + {showMetrics && } {groupByDate(group.items).map((dateGroup) => ( 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, + }; } \ No newline at end of file -- 2.49.1 From fc3a48a36723d45422c2a7cf3a3732704eb64233 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 16:20:06 +0530 Subject: [PATCH 06/15] refactor: reuse FkMultiSelectField for report period/payee selectors Export FkMultiSelectField from react-openapi and use it directly in the report viewer for the Period/Payee dimension filters instead of the custom OptionMultiSelect component, gaining the admin panel's QoL behavior (no close-on-click, selected-first with tick marks, chips). Feed it fabricated FieldConfigs plus fkOptions derived from the report's metadata.group_options; delete the standalone OptionMultiSelect. --- react-openapi/index.ts | 1 + src/Reports/ReportViewer.tsx | 69 ++++++++++++++++----- src/common/components/OptionMultiSelect.tsx | 55 ---------------- 3 files changed, 56 insertions(+), 69 deletions(-) delete mode 100644 src/common/components/OptionMultiSelect.tsx diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 0f1a3ac..963d1f2 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -6,6 +6,7 @@ export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField"; +export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField"; export { SseStreamView } from "./src/components/SseStreamView"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; export { getApi } from "./src/hooks/useApi"; diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index f01f51e..3a291cf 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -10,14 +10,42 @@ import { } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import CachedIcon from "@mui/icons-material/Cached"; -import { useResource, formatCurrency } from "../../react-openapi"; +import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi"; +import type { FieldConfig } from "../../react-openapi"; import { StatCard } from "../common/components/StatCard"; import { TransactionList } from "../common/components/TransactionList"; -import { OptionMultiSelect } from "../common/components/OptionMultiSelect"; import type { TxnFieldConfigs } from "../common/types"; import { toPeriodGranularity } from "../common/utils/transactions"; import { aggregateSlice } from "./types"; +const periodField: FieldConfig = { + name: "period", + label: "Period", + description: "", + type: "string", + order: 0, + hidden: {}, + filterable: true, + sortable: false, + readOnly: false, + required: false, + isArray: true, +}; + +const payeeField: FieldConfig = { + name: "payee", + label: "Payee", + description: "", + type: "string", + order: 0, + hidden: {}, + filterable: true, + sortable: false, + readOnly: false, + required: false, + isArray: true, +}; + interface ReportViewerProps { id: string; version: number; @@ -70,6 +98,15 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re }; }, [report]); + const periodOptions = useMemo( + () => groupOptions.period.map((label: string) => ({ value: label, label })), + [groupOptions], + ); + const payeeOptions = useMemo( + () => groupOptions.payee.map((label: string) => ({ value: label, label })), + [groupOptions], + ); + const slice = useMemo( () => aggregateSlice(data ?? [], { periods: selectedPeriods, payees: selectedPayees }), [data, selectedPeriods, selectedPayees], @@ -149,18 +186,22 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : ( - - + + + + + + Showing {slice.count} transactions across {slice.txns.length} rows diff --git a/src/common/components/OptionMultiSelect.tsx b/src/common/components/OptionMultiSelect.tsx deleted file mode 100644 index 0a4d23b..0000000 --- a/src/common/components/OptionMultiSelect.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 ( - onChange(newVal)} - disabled={options.length === 0} - renderOption={(props, option, { selected }) => ( -
  • - {selected ? : } - {option} -
  • - )} - renderTags={(tagValue, getTagProps) => { - const maxChips = 1; - return ( - <> - {tagValue.slice(0, maxChips).map((tag, index) => { - const { key, ...tagProps } = getTagProps({ index }); - return 12 ? `${tag.slice(0, 10)}..` : tag} size="small" />; - })} - {tagValue.length > maxChips && } - - ); - }} - renderInput={(params) => ( - - )} - /> - ); -} \ No newline at end of file -- 2.49.1 From f865f7dec23431c816ce0ea03d24906a21cc3b0d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 16:37:05 +0530 Subject: [PATCH 07/15] feat: show per-group report stats in collapsed accordion header Move the Sum/Count/Avg/Min/Max/First/Last metric row from inside the expanded accordion details into the accordion summary as a second row, so group stats are visible while the group is collapsed. Second row renders compact label-value pairs instead of pills; summary content is now a two-row column. --- src/common/components/TransactionList.tsx | 43 +++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index b6898a1..db26e34 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -165,31 +165,36 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe px: 2, py: 1, "& .MuiAccordionSummary-content": { - alignItems: "center", - gap: 1.5, + flexDirection: "column", + alignItems: "stretch", + gap: 0.75, minWidth: 0, }, }} > - - {group.label} - - - {group.items.length} transaction{group.items.length === 1 ? "" : "s"} - - - - {formatCurrency(group.spent, group.currency)} - - - / - - - {formatCurrency(group.income, group.currency)} - + + + {group.label} + + + {group.items.length} transaction{group.items.length === 1 ? "" : "s"} + + + + {formatCurrency(group.spent, group.currency)} + + + / + + + {formatCurrency(group.income, group.currency)} + + + + {showMetrics && } + - {showMetrics && } {groupByDate(group.items).map((dateGroup) => ( Date: Thu, 20 Aug 2026 17:10:48 +0530 Subject: [PATCH 08/15] 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. --- src/common/components/TransactionList.tsx | 27 +++++++++-------------- src/common/utils/transactions.ts | 25 ++++++++++++++------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index db26e34..80c84b1 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -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 ( - + {rows.map((row) => ( - - - {row.label} - - - {row.value} - - + ))} ); @@ -190,9 +187,7 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe {formatCurrency(group.income, group.currency)} - - {showMetrics && } - + {showMetrics && } diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index c72e13b..dd9996e 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -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, }; } \ No newline at end of file -- 2.49.1 From 6b340d89f64ec445bad94a516ba482d3eacb759f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 19:52:43 +0530 Subject: [PATCH 09/15] refactor: revamp for older report logic with benefits of newer one --- react-openapi/src/hooks/useApi.ts | 19 ++ src/Reports/GenerateReportPanel.tsx | 292 +++++++++++++++++----------- src/Reports/Report.tsx | 26 ++- src/Reports/ReportList.tsx | 42 ++-- src/Reports/ReportViewer.tsx | 279 +++++++++++++++++--------- src/Reports/types.ts | 192 ++++++++---------- 6 files changed, 502 insertions(+), 348 deletions(-) diff --git a/react-openapi/src/hooks/useApi.ts b/react-openapi/src/hooks/useApi.ts index 7d7b5b4..c201b5b 100644 --- a/react-openapi/src/hooks/useApi.ts +++ b/react-openapi/src/hooks/useApi.ts @@ -3,6 +3,24 @@ import axios, { AxiosInstance } from "axios"; let apiClient: AxiosInstance | null = null; let _onUnauthorized: (() => void) | undefined; +function serializeParams(params: Record): string { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) searchParams.append(key, String(item)); + } else if (typeof value === "object") { + for (const [nestedKey, nestedValue] of Object.entries(value)) { + if (nestedValue === undefined || nestedValue === null) continue; + searchParams.append(`${key}[${nestedKey}]`, String(nestedValue)); + } + } else { + searchParams.append(key, String(value)); + } + } + return searchParams.toString(); +} + export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance { if (apiClient && apiClient.defaults.baseURL === baseUrl) { _onUnauthorized = onUnauthorized; @@ -14,6 +32,7 @@ export function initApi(baseUrl: string, getToken?: () => string | null, onUnaut apiClient = axios.create({ baseURL: baseUrl, headers: { "Content-Type": "application/json" }, + paramsSerializer: serializeParams, }); apiClient.interceptors.request.use((config) => { diff --git a/src/Reports/GenerateReportPanel.tsx b/src/Reports/GenerateReportPanel.tsx index c634d05..8f596be 100644 --- a/src/Reports/GenerateReportPanel.tsx +++ b/src/Reports/GenerateReportPanel.tsx @@ -5,78 +5,73 @@ import { Typography, TextField, Button, - IconButton, MenuItem, Select, FormControl, InputLabel, Autocomplete, + FormControlLabel, + Checkbox, + Chip, Alert, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; -import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import { useResource, useAppContext, applyDisplayFormat } from "../../react-openapi"; import { useToast } from "../ui/Toast"; import { isDdmmyyyy } from "../common/utils/dates"; -import { apiErrorMessage, groupTypeEnum, periodHints } from "./types"; - -interface GroupRow { - id: number; - group_type: string; - group_value: string; -} +import { apiErrorMessage, granularityOptions, groupDimOptions, FLOW_OPTIONS } from "./types"; interface GenerateReportPanelProps { - onGenerated: (reports: any[]) => void; + onGenerated: (report: any) => void; } +const ALL_GRANULARITIES = ["weekly", "monthly", "quarterly"]; + export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { const { schemas, resources } = useAppContext(); const { create } = useResource("reports"); - const { list: listEntities } = useResource("entities"); + const { list: listAccounts } = useResource("accounts"); const { showToast } = useToast(); - const [rows, setRows] = useState([{ id: 1, group_type: "monthly", group_value: "*" }]); + const [name, setName] = useState(""); + const [granularities, setGranularities] = useState(ALL_GRANULARITIES); + const [groupDims, setGroupDims] = useState(["payee", "tag"]); + const [flow, setFlow] = useState("both"); + const [accounts, setAccounts] = useState([]); + const [ignoreSelf, setIgnoreSelf] = useState(true); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); + const [minAmount, setMinAmount] = useState(""); + const [maxAmount, setMaxAmount] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [entityNames, setEntityNames] = useState([]); + const [accountOptions, setAccountOptions] = useState([]); const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({}); - const types = useMemo(() => groupTypeEnum(schemas), [schemas]); - const entitiesRes = resources.find((r) => r.name === "entities"); - const entitiesFormat = entitiesRes?.displayFormat ?? "{name}"; + const granularityChoices = useMemo(() => { + const enums = granularityOptions(schemas); + return enums.length ? enums : ALL_GRANULARITIES; + }, [schemas]); + const dimChoices = useMemo(() => groupDimOptions(schemas), [schemas]); + const accountsRes = resources.find((r) => r.name === "accounts"); + const accountsFormat = accountsRes?.displayFormat ?? "{name}"; useEffect(() => { let mounted = true; - listEntities({ limit: 0 }).then((res) => { + listAccounts({ limit: 0 }).then((res) => { if (!mounted) return; const names = (res.items ?? []) - .map((it: any) => applyDisplayFormat(it, entitiesFormat)) + .map((it: any) => applyDisplayFormat(it, accountsFormat)) .filter((n: string) => n); - setEntityNames([...new Set(names)].sort((a, b) => a.localeCompare(b))); + setAccountOptions([...new Set(names)].sort((a, b) => a.localeCompare(b))); }); return () => { mounted = false; }; - }, [listEntities, entitiesFormat]); + }, [listAccounts, accountsFormat]); - const isPayee = (type: string) => type === "payee"; - - const valueOptions = (row: GroupRow): string[] => ["*", ...(isPayee(row.group_type) ? entityNames : [])]; - - const valueHint = (row: GroupRow): string => - isPayee(row.group_type) ? "Entity name, or * for all payees" : `e.g. ${periodHints(row.group_type).join(", ")}, or *`; - - const nextRowId = () => Math.max(0, ...rows.map((r) => r.id)) + 1; - - const addRow = () => setRows((rs) => [...rs, { id: nextRowId(), group_type: "monthly", group_value: "*" }]); - - const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id)); - - const updateRow = (id: number, patch: Partial) => - setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); + const toggle = (list: string[], value: string, setter: (v: string[]) => void) => + setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]); const validateDates = (): boolean => { const errs: { start?: string; end?: string } = {}; @@ -86,25 +81,47 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { return Object.keys(errs).length === 0; }; + const parseAmount = (value: string): number | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; + }; + const handleSubmit = async () => { if (!validateDates()) return; setError(null); - const groups = rows - .filter((r) => r.group_type && r.group_value.trim()) - .map(({ group_type, group_value }) => ({ group_type, group_value: group_value.trim() })); - if (groups.length === 0) { - setError("At least one group is required"); + const min = parseAmount(minAmount); + const max = parseAmount(maxAmount); + if (minAmount.trim() && min === null) { + setError("Min amount must be a number"); return; } - setSubmitting(true); - const payload: Record = { groups }; + if (maxAmount.trim() && max === null) { + setError("Max amount must be a number"); + return; + } + if (min !== null && max !== null && min > max) { + setError("Min amount cannot exceed max amount"); + return; + } + const payload: Record = { + name: name.trim(), + granularities, + group_dims: groupDims, + flow, + ignore_self: ignoreSelf, + }; + if (accounts.length) payload.accounts = accounts; if (startDate.trim()) payload.start_date = startDate.trim(); if (endDate.trim()) payload.end_date = endDate.trim(); + if (min !== null) payload.min_amount = min; + if (max !== null) payload.max_amount = max; + setSubmitting(true); try { const created = await create(payload); - const list = Array.isArray(created) ? created : created ? [created] : []; - showToast(`Generated ${list.length} report${list.length === 1 ? "" : "s"}`); - onGenerated(list); + showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot"); + onGenerated(created); } catch (e: any) { setError(apiErrorMessage(e)); } finally { @@ -118,8 +135,8 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { Generate report - Choose the dimensions to snapshot. At least one group is required — with no period dimension the server - snapshots weekly, monthly and quarterly. + Define the snapshot's scope. Granularity, payee and tag are sliced at view time — the cube is built once and + every combination stays cheap to read. {error && ( @@ -128,81 +145,128 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { )} - - {rows.map((row) => ( - - - Type - - - updateRow(row.id, { group_value: newVal ?? "" })} - renderInput={(params) => ( - - )} - /> - removeRow(row.id)} - sx={{ mt: 0.25 }} - > - - + + setName(e.target.value)} + sx={{ maxWidth: 420 }} + /> + + + + Granularities + + + {granularityChoices.map((g) => ( + toggle(granularities, g, setGranularities)} + /> + ))} - ))} - + - + + + Group dimensions + + + {dimChoices.map((d) => ( + toggle(groupDims, d, setGroupDims)} + /> + ))} + + - - + + Flow + + + setIgnoreSelf(e.target.checked)} />} + label="Ignore self-transfers" + sx={{ mt: 0.25 }} + /> + + + setStartDate(e.target.value)} - error={Boolean(dateErrors.start)} - helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} - sx={{ width: 200 }} - /> - setEndDate(e.target.value)} - error={Boolean(dateErrors.end)} - helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} - sx={{ width: 200 }} + options={accountOptions} + value={accounts} + onChange={(_, newVal) => setAccounts(newVal)} + renderInput={(params) => ( + + )} + sx={{ maxWidth: 420 }} /> + + + setStartDate(e.target.value)} + error={Boolean(dateErrors.start)} + helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} + sx={{ width: 200 }} + /> + setEndDate(e.target.value)} + error={Boolean(dateErrors.end)} + helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} + sx={{ width: 200 }} + /> + + + + setMinAmount(e.target.value)} + sx={{ width: 200 }} + /> + setMaxAmount(e.target.value)} + sx={{ width: 200 }} + /> + - diff --git a/src/Reports/Report.tsx b/src/Reports/Report.tsx index d8426b0..0f45bdc 100644 --- a/src/Reports/Report.tsx +++ b/src/Reports/Report.tsx @@ -11,7 +11,7 @@ import { } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import AssessmentIcon from "@mui/icons-material/Assessment"; -import { useResource, useAppContext, formatCurrency } from "../../react-openapi"; +import { useResource, useAppContext } from "../../react-openapi"; import { useToast } from "../ui/Toast"; import { PageHeader } from "../ui/PageHeader"; import { EmptyState } from "../ui/EmptyState"; @@ -45,22 +45,19 @@ export default function Report() { }, [load]); const handleGenerated = useCallback( - (created: any[]) => { + (created: any) => { load(); - if (created?.[0]?.id) setSelectedId(created[0].id); + if (created?.id) setSelectedId(created.id); }, [load], ); const handleRegenerate = useCallback( async (report: any) => { - const payload: Record = { groups: report.groups ?? [] }; - if (report.start_date) payload.start_date = report.start_date; - if (report.end_date) payload.end_date = report.end_date; try { - await create(payload); + const created = await create(report.query ?? {}); showToast("Report regenerated"); - setSelectedId(report.id); + setSelectedId(created?.id ?? report.id); setViewerVersion((v) => v + 1); load(); } catch (e: any) { @@ -86,9 +83,9 @@ export default function Report() { const summary = useMemo(() => { const rows = reports ?? []; - const txnCount = rows.reduce((s, r) => s + (r.txn_count ?? 0), 0); - const total = rows.reduce((s, r) => s + (typeof r.metrics?.sum === "number" ? r.metrics.sum : 0), 0); - return { count: rows.length, txnCount, total }; + const granularities = new Set(); + for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g); + return { count: rows.length, granularities: [...granularities].join(", ") }; }, [reports]); return ( @@ -96,7 +93,7 @@ export default function Report() { {error && ( @@ -107,8 +104,7 @@ export default function Report() { - - + @@ -154,7 +150,7 @@ export default function Report() { } 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)." + description="Generate your first snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag." />
    ) : ( diff --git a/src/Reports/ReportList.tsx b/src/Reports/ReportList.tsx index 3813d5c..ad264cf 100644 --- a/src/Reports/ReportList.tsx +++ b/src/Reports/ReportList.tsx @@ -3,7 +3,7 @@ import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import VisibilityIcon from "@mui/icons-material/Visibility"; import CachedIcon from "@mui/icons-material/Cached"; -import { ListCellRenderer, formatCurrency } from "../../react-openapi"; +import { ListCellRenderer } from "../../react-openapi"; import type { ReportFieldConfigs } from "./types"; interface ReportListProps { @@ -30,10 +30,14 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg return ( {reports.map((report) => { - const sum = typeof report.metrics?.sum === "number" ? report.metrics.sum : null; - const range = - report.start_date || report.end_date - ? `range ${report.start_date || "…"} → ${report.end_date || "…"}` + const q = report.query ?? {}; + const range = q.start_date || q.end_date ? `range ${q.start_date || "…"} → ${q.end_date || "…"}` : null; + const dims = Array.isArray(q.group_dims) ? q.group_dims.join(", ") : ""; + const granularities = Array.isArray(q.granularities) ? q.granularities.join(", ") : ""; + const accounts = Array.isArray(q.accounts) ? `${q.accounts.length} account${q.accounts.length === 1 ? "" : "s"}` : "all accounts"; + const amounts = + q.min_amount != null || q.max_amount != null + ? `amount ${q.min_amount ?? "0"} → ${q.max_amount ?? "∞"}` : null; return ( - {report.group_label} + {report.name || report.id} - {fields && ( - - )} - {report.period_label} + {granularities} / - {report.payee} + {dims} {range && ( @@ -73,14 +74,13 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg - {report.entity_count ?? 0} entities - - - {report.txn_count ?? 0} txns - - - {report.expense_count ?? 0} expenses + {accounts} + {amounts && ( + + {amounts} + + )} {fields && ( @@ -89,12 +89,6 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg - - - {sum != null ? formatCurrency(sum, "INR") : "—"} - - - + + + + {granularityOptions.map((g: string) => ( + setGranularity(g)} + /> + ))} + + + Flow + + - ) : ( - - - - - - - - - - Showing {slice.count} transactions across {slice.txns.length} rows + + + + + + + + + + + + + {slice.txns.length} transactions · {slice.count} rows + + + + + + + + + + + {bars.length === 0 ? ( + + + No data for this slice. Try another granularity, period or payer. - + + ) : ( + + {bars.map((b) => ( + + + {b.periodId} + + + + {formatCurrency(b.sum, slice.currency)} + + + {b.count} txn{b.count === 1 ? "" : "s"} + + + ))} + + )} - - - - - - - {slice.txns.length === 0 ? ( - - - No transactions in this slice. - - - ) : fields ? ( - - ) : null} - - )} + {slice.txns.length === 0 ? null : fields ? ( + + ) : null} +
    ); } \ No newline at end of file diff --git a/src/Reports/types.ts b/src/Reports/types.ts index c9e918e..9696dbd 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -1,23 +1,4 @@ import type { FieldConfig, ResourceConfig } from "../../react-openapi"; -import { parseDdmmyyyy } from "../common/utils/dates"; - -export interface ParsedGroupKey { - period?: { granularity: string; label: string }; - payee?: { label: string }; - range?: { start?: string; end?: string }; -} - -export interface ReportGroupLike { - key: string; - group_label?: string; - metrics?: Record; - txns?: any[]; -} - -export interface SliceFilter { - periods?: string[]; - payees?: string[]; -} export interface SliceSummary { sum: number; @@ -33,12 +14,23 @@ export interface SliceSummary { currency: string; } +export interface SliceFilter { + granularity: string; + periods?: string[]; + payees?: string[]; + tags?: string[]; +} + +export interface PeriodSlice { + periodId: string; + sum: number; + count: number; + firstDate: string | null; + lastDate: string | null; +} + export interface ReportFieldConfigs { - groupLabel: FieldConfig; - granularity: FieldConfig; - periodLabel: FieldConfig; - payee: FieldConfig; - entityCount: FieldConfig; + name: FieldConfig; generatedAt: FieldConfig; } @@ -48,23 +40,6 @@ export interface MetricLabel { order: number; } -/** Split a concrete cache key into its dimension parts, e.g. `period:monthly:2026-Jan|payee:Zepto`. */ -export function parseCacheKey(key: string): ParsedGroupKey { - const out: ParsedGroupKey = {}; - for (const dim of key.split("|")) { - if (!dim) continue; - const [name, ...rest] = dim.split(":"); - if (name === "period" && rest.length >= 2) { - out.period = { granularity: rest[0], label: rest.slice(1).join(":") }; - } else if (name === "payee" && rest.length >= 1) { - out.payee = { label: rest.join(":") }; - } else if (name === "range" && rest.length >= 1) { - out.range = { start: rest[0] || undefined, end: rest[1] || undefined }; - } - } - return out; -} - export function apiErrorMessage(e: any): string { if (e?.response?.data) { const d = e.response.data; @@ -76,15 +51,16 @@ export function apiErrorMessage(e: any): string { return e?.message ?? "Request failed"; } -export function groupTypeEnum(schemas: Record): string[] { - return schemas?.GroupSpec?.properties?.group_type?.enum ?? []; +export function granularityOptions(schemas: Record): string[] { + return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? []; } -export function groupValueFk(schemas: Record): { resource?: string; prefetch?: boolean } | null { - const fk = schemas?.GroupSpec?.properties?.group_value?.["x-fk"]; - return fk && typeof fk === "object" ? fk : null; +export function groupDimOptions(schemas: Record): string[] { + return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"]; } +export const FLOW_OPTIONS = ["both", "inflows", "outflows"]; + export function metricLabels(schemas: Record): MetricLabel[] { const props: Record = schemas?.ReportMetrics?.properties ?? {}; return Object.entries(props) @@ -97,42 +73,47 @@ export function metricLabels(schemas: Record): MetricLabel[] { .sort((a, b) => a.order - b.order); } -export function periodHints(granularity: string): string[] { - const y = new Date().getFullYear(); - switch (granularity) { - case "weekly": - return [`${y}-W01`, `${y}-W26`]; - case "monthly": - return [`${y}-Jan`, `${y}-Feb`]; - case "quarterly": - return [`${y}-Jan-Mar`, `${y}-Apr-Jun`]; - case "yearly": - return [`${y - 1}`, `${y}`]; - default: - return [`${y}-Jan`, `${y}-W01`, `${y}-Jan-Mar`, `${y}`]; - } -} - function dateVal(value: string): number { - try { - return parseDdmmyyyy(value).getTime(); - } catch { - return 0; - } + const t = new Date(value).getTime(); + return Number.isNaN(t) ? 0 : t; } -export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean { - const key = parseCacheKey(group.key); - if (filter.periods && filter.periods.length > 0) { - if (!key.period || !filter.periods.includes(key.period.label)) return false; - } - if (filter.payees && filter.payees.length > 0) { - if (!key.payee || !filter.payees.includes(key.payee.label)) return false; - } +function bucketMatches(bucket: any, filter: SliceFilter): boolean { + const gk = bucket?.group_key ?? {}; + if (filter.payees?.length && !(gk.payee ?? []).some((p: string) => filter.payees?.includes(p))) return false; + if (filter.tags?.length && !(gk.tag ?? []).some((t: string) => filter.tags?.includes(t))) return false; return true; } -export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter): SliceSummary { +export function periodSlices(buckets: any[], filter: SliceFilter): PeriodSlice[] { + const byPeriod = new Map(); + 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()]; +} + +export function aggregateSlice(buckets: any[], filter: SliceFilter): SliceSummary { let sum = 0; let count = 0; let spent = 0; @@ -145,30 +126,33 @@ export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter): const txns: any[] = []; const seen = new Set(); - for (const group of groups) { - if (!groupMatches(group, filter)) continue; - const m = group.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); - } - if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) { - lastDate = String(m.last_date); - } - for (const txn of group.txns ?? []) { - if (txn?.id != null) { - if (seen.has(txn.id)) continue; - seen.add(txn.id); + 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); + } + if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) { + 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); + } + txns.push(txn); + const amt = Number(txn?.amount ?? 0); + if (amt < 0) spent += Math.abs(amt); + else income += amt; + const c = txn?.account?.currency; + if (c) currency = c; } - txns.push(txn); - const amt = Number(txn?.amount ?? 0); - if (amt < 0) spent += Math.abs(amt); - else income += amt; - const c = txn?.account?.currency; - if (c) currency = c; } } @@ -179,12 +163,8 @@ export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFiel const reportsRes = resources.find((r) => r.name === "reports"); if (!reportsRes) return null; const find = (name: string) => reportsRes.fields.find((f) => f.name === name); - const groupLabel = find("group_label"); - const granularity = find("granularity"); - const periodLabel = find("period_label"); - const payee = find("payee"); - const entityCount = find("entity_count"); + const name = find("name"); const generatedAt = find("generated_at"); - if (!groupLabel || !granularity || !periodLabel || !payee || !entityCount || !generatedAt) return null; - return { groupLabel, granularity, periodLabel, payee, entityCount, generatedAt }; + if (!name || !generatedAt) return null; + return { name, generatedAt }; } \ No newline at end of file -- 2.49.1 From e8faafae7d14940fba23c17ab6bba4fb5356609a Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 15:45:23 +0530 Subject: [PATCH 10/15] feat(reports): render generate-report form from OpenAPI spec Replace hand-written GenerateReportPanel inputs with a generic loop over extractFields("ReportQuery") + FormFieldRenderer; submit via useResource("reports").create with server-side validation only. react-openapi additions (all additive): - lift items.enum into FieldConfig.enumValues for array-of-enum props - extract schema `default` into FieldConfig.defaultValue - new MultiEnumField renderer dispatched for isArray && enumValues - FKFieldConfig.value: bind option values to a target property instead of the primary key (account names); sanitize-payload skips resolveFk when set - new useFkFieldOptions hook encapsulating FK option loading/prefetch - export extractFields, useFkFieldOptions, MultiEnumField Drop now-unused granularityOptions/groupDimOptions helpers. --- react-openapi/index.ts | 4 + .../components/fields/FormFieldRenderer.tsx | 12 + react-openapi/src/components/fields/index.ts | 1 + .../fields/renderers/MultiEnumField.tsx | 59 ++++ react-openapi/src/hooks/useFkFieldOptions.ts | 82 +++++ .../src/transformers/field-config.ts | 5 +- react-openapi/src/types.ts | 4 + react-openapi/src/utils/sanitize-payload.ts | 4 +- src/Reports/GenerateReportPanel.tsx | 291 +++++------------- src/Reports/types.ts | 8 - 10 files changed, 240 insertions(+), 230 deletions(-) create mode 100644 react-openapi/src/components/fields/renderers/MultiEnumField.tsx create mode 100644 react-openapi/src/hooks/useFkFieldOptions.ts diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 963d1f2..0dd032d 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -5,6 +5,10 @@ export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; +export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField"; +export { extractFields } from "./src/transformers/field-config"; +export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions"; +export type { FkOption } from "./src/hooks/useFkFieldOptions"; export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField"; export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField"; export { SseStreamView } from "./src/components/SseStreamView"; diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx index 4b21f66..eb6a896 100644 --- a/react-openapi/src/components/fields/FormFieldRenderer.tsx +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -6,6 +6,7 @@ import { NumberField } from "./renderers/NumberField"; import { DateField } from "./renderers/DateField"; import { BooleanField } from "./renderers/BooleanField"; import { EnumField } from "./renderers/EnumField"; +import { MultiEnumField } from "./renderers/MultiEnumField"; import { FkSelectField } from "./renderers/FkSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FileUploadField } from "./renderers/FileUploadField"; @@ -81,6 +82,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk ); } + if (field.isArray && field.enumValues) { + return ( + + ); + } + if (field.enumValues) { return ( void; + error?: string; +} + +export function MultiEnumField({ field, value, onChange, error }: Props) { + const selected: string[] = Array.isArray(value) ? value : []; + + return ( + onChange(newVal)} + renderOption={(props, option, { selected: isSelected }) => { + const { key, ...rest } = props as any; + return ( +
  • + {isSelected ? ( + + ) : ( + + )} + {option} +
  • + ); + }} + renderTags={(tagValue, getTagProps) => + tagValue.map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ; + }) + } + renderInput={(params) => ( + + )} + sx={{ + "& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 }, + }} + disabled={field.readOnly} + /> + ); +} diff --git a/react-openapi/src/hooks/useFkFieldOptions.ts b/react-openapi/src/hooks/useFkFieldOptions.ts new file mode 100644 index 0000000..47497a7 --- /dev/null +++ b/react-openapi/src/hooks/useFkFieldOptions.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FieldConfig, FKFieldConfig } from "../types"; +import { useAppContext } from "../context/AppContext"; +import { getApi } from "./useApi"; + +export interface FkOption { + value: any; + label: string; +} + +function applyFormat(obj: any, format: string): string { + if (!obj || typeof obj !== "object") return String(obj ?? ""); + return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? "")); +} + +/** + * Loads select options for FK-annotated fields of a field set. + * Option values bind to `fk.value` when set (e.g. account names), + * otherwise to the target resource's primary key. + */ +export function useFkFieldOptions(fields: FieldConfig[]) { + const { resources } = useAppContext(); + const [fkOptions, setFkOptions] = useState>({}); + const [fkLoading, setFkLoading] = useState>({}); + + const loadFkOptions = useCallback( + async (fieldName: string, fk: FKFieldConfig) => { + setFkLoading((prev) => ({ ...prev, [fieldName]: true })); + try { + const targetRes = resources.find((r) => r.name === fk.resource); + if (!targetRes) return; + + const api = getApi(); + const params: Record = {}; + if (targetRes.pagination) params.limit = 0; + const res = await api.get(targetRes.path, { params }); + + let items: any[]; + if (targetRes.pagination) { + if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) { + throw new Error(`Expected paginated response from ${targetRes.path}`); + } + items = res.data.items; + } else { + if (!Array.isArray(res.data)) { + throw new Error(`Expected array response from ${targetRes.path}`); + } + items = res.data; + } + + const opts: FkOption[] = items.map((item: any) => ({ + value: item[fk.value ?? targetRes.primaryKey], + label: applyFormat(item, targetRes.displayFormat), + })); + setFkOptions((prev) => ({ ...prev, [fieldName]: opts })); + } catch { + // leave options empty; the field renders without suggestions + } finally { + setFkLoading((prev) => ({ ...prev, [fieldName]: false })); + } + }, + [resources] + ); + + useEffect(() => { + fields.forEach((f) => { + if (f.fk?.prefetch) loadFkOptions(f.name, f.fk); + }); + }, [fields, loadFkOptions]); + + const loadFkOnOpen = useCallback( + (fieldName: string) => { + const field = fields.find((f) => f.name === fieldName); + if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) { + loadFkOptions(fieldName, field.fk); + } + }, + [fields, fkOptions, loadFkOptions] + ); + + return { fkOptions, fkLoading, loadFkOnOpen }; +} diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts index 87a0e14..cbdb793 100644 --- a/react-openapi/src/transformers/field-config.ts +++ b/react-openapi/src/transformers/field-config.ts @@ -136,7 +136,10 @@ export function extractFields(schemaName: string, schema: any, schemas: Record void; } -const ALL_GRANULARITIES = ["weekly", "monthly", "quarterly"]; - export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { - const { schemas, resources } = useAppContext(); - const { create } = useResource("reports"); - const { list: listAccounts } = useResource("accounts"); + const { schemas } = useAppContext(); + const { create, loading: submitting } = useResource("reports"); const { showToast } = useToast(); - const [name, setName] = useState(""); - const [granularities, setGranularities] = useState(ALL_GRANULARITIES); - const [groupDims, setGroupDims] = useState(["payee", "tag"]); - const [flow, setFlow] = useState("both"); - const [accounts, setAccounts] = useState([]); - const [ignoreSelf, setIgnoreSelf] = useState(true); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [minAmount, setMinAmount] = useState(""); - const [maxAmount, setMaxAmount] = useState(""); - const [submitting, setSubmitting] = useState(false); + const [formData, setFormData] = useState>({}); const [error, setError] = useState(null); - const [accountOptions, setAccountOptions] = useState([]); - const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({}); - const granularityChoices = useMemo(() => { - const enums = granularityOptions(schemas); - return enums.length ? enums : ALL_GRANULARITIES; + const fields: FieldConfig[] = useMemo(() => { + const schema = schemas?.ReportQuery; + if (!schema) return []; + return extractFields("ReportQuery", schema, schemas).sort( + (a, b) => a.order - b.order || a.name.localeCompare(b.name) + ); }, [schemas]); - const dimChoices = useMemo(() => groupDimOptions(schemas), [schemas]); - const accountsRes = resources.find((r) => r.name === "accounts"); - const accountsFormat = accountsRes?.displayFormat ?? "{name}"; + + const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields); useEffect(() => { - let mounted = true; - listAccounts({ limit: 0 }).then((res) => { - if (!mounted) return; - const names = (res.items ?? []) - .map((it: any) => applyDisplayFormat(it, accountsFormat)) - .filter((n: string) => n); - setAccountOptions([...new Set(names)].sort((a, b) => a.localeCompare(b))); + setFormData((prev) => { + const next = { ...prev }; + let changed = false; + for (const f of fields) { + if (next[f.name] === undefined && f.defaultValue !== undefined) { + next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue; + changed = true; + } + } + return changed ? next : prev; }); - return () => { - mounted = false; - }; - }, [listAccounts, accountsFormat]); + }, [fields]); - const toggle = (list: string[], value: string, setter: (v: string[]) => void) => - setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]); - - const validateDates = (): boolean => { - const errs: { start?: string; end?: string } = {}; - if (startDate && !isDdmmyyyy(startDate)) errs.start = "Use DD-MM-YYYY"; - if (endDate && !isDdmmyyyy(endDate)) errs.end = "Use DD-MM-YYYY"; - setDateErrors(errs); - return Object.keys(errs).length === 0; - }; - - const parseAmount = (value: string): number | null => { - const trimmed = value.trim(); - if (!trimmed) return null; - const n = Number(trimmed); - return Number.isFinite(n) ? n : null; - }; - - const handleSubmit = async () => { - if (!validateDates()) return; + const handleChange = useCallback((name: string, value: any) => { + setFormData((prev) => ({ ...prev, [name]: value })); setError(null); - const min = parseAmount(minAmount); - const max = parseAmount(maxAmount); - if (minAmount.trim() && min === null) { - setError("Min amount must be a number"); - return; + }, []); + + const buildPayload = (): Record => { + const payload: Record = {}; + for (const f of fields) { + const v = formData[f.name]; + if (v === undefined || v === null || v === "") continue; + if (Array.isArray(v) && v.length === 0) continue; + payload[f.name] = v; } - if (maxAmount.trim() && max === null) { - setError("Max amount must be a number"); - return; - } - if (min !== null && max !== null && min > max) { - setError("Min amount cannot exceed max amount"); - return; - } - const payload: Record = { - name: name.trim(), - granularities, - group_dims: groupDims, - flow, - ignore_self: ignoreSelf, - }; - if (accounts.length) payload.accounts = accounts; - if (startDate.trim()) payload.start_date = startDate.trim(); - if (endDate.trim()) payload.end_date = endDate.trim(); - if (min !== null) payload.min_amount = min; - if (max !== null) payload.max_amount = max; - setSubmitting(true); + return payload; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); try { - const created = await create(payload); + const created = await create(buildPayload()); showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot"); onGenerated(created); } catch (e: any) { setError(apiErrorMessage(e)); - } finally { - setSubmitting(false); } }; @@ -145,131 +99,28 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { )} - - setName(e.target.value)} - sx={{ maxWidth: 420 }} - /> - - - - Granularities - - - {granularityChoices.map((g) => ( - toggle(granularities, g, setGranularities)} + + + {fields.map((field) => ( + + handleChange(field.name, val)} + fkOptions={fkOptions[field.name]} + fkLoading={fkLoading[field.name]} + onFkOpen={loadFkOnOpen} /> - ))} - + + ))} + + + + - - - - Group dimensions - - - {dimChoices.map((d) => ( - toggle(groupDims, d, setGroupDims)} - /> - ))} - - - - - - Flow - - - setIgnoreSelf(e.target.checked)} />} - label="Ignore self-transfers" - sx={{ mt: 0.25 }} - /> - - - setAccounts(newVal)} - renderInput={(params) => ( - - )} - sx={{ maxWidth: 420 }} - /> - - - setStartDate(e.target.value)} - error={Boolean(dateErrors.start)} - helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} - sx={{ width: 200 }} - /> - setEndDate(e.target.value)} - error={Boolean(dateErrors.end)} - helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} - sx={{ width: 200 }} - /> - - - - setMinAmount(e.target.value)} - sx={{ width: 200 }} - /> - setMaxAmount(e.target.value)} - sx={{ width: 200 }} - /> - - - - - ); -} \ No newline at end of file +} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 9696dbd..c9a990d 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -51,14 +51,6 @@ export function apiErrorMessage(e: any): string { return e?.message ?? "Request failed"; } -export function granularityOptions(schemas: Record): string[] { - return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? []; -} - -export function groupDimOptions(schemas: Record): string[] { - return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"]; -} - export const FLOW_OPTIONS = ["both", "inflows", "outflows"]; export function metricLabels(schemas: Record): MetricLabel[] { -- 2.49.1 From d979e443be512d51c9980d16996cccace6b115de Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 16:45:47 +0530 Subject: [PATCH 11/15] refactor(reports): render API metrics verbatim via period groups --- src/Reports/ReportViewer.tsx | 59 ++++--- src/Reports/types.ts | 202 +++++++++++++++------- src/common/components/TransactionList.tsx | 71 ++++++-- src/common/utils/transactions.ts | 23 ++- 4 files changed, 256 insertions(+), 99 deletions(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index 72acbe9..a294eff 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -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( + () => + 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 - - - + + + - {bars.length === 0 ? ( + {periodGroups.length === 0 ? ( No data for this slice. Try another granularity, period or payer. @@ -290,10 +312,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : ( - {bars.map((b) => ( - + {periodGroups.map((g) => ( + - {b.periodId} + {g.key} - {formatCurrency(b.sum, slice.currency)} + {formatCurrency(g.metrics.sum, slice.currency)} - {b.count} txn{b.count === 1 ? "" : "s"} + {g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"} ))} @@ -317,12 +339,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re )} {slice.txns.length === 0 ? null : fields ? ( - + ) : null} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index c9a990d..fbda26d 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -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(); - 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(); + const seenTxnIds = new Set(); let currency = "INR"; - const txns: any[] = []; - const seen = new Set(); 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 { diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index 80c84b1..072c867 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -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 ( {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(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); - const groups = useMemo(() => groupByPeriod(items, granularity), [items, granularity]); + const groups = useMemo( + () => externalGroups ?? groupByPeriod(items ?? [], granularity), + [externalGroups, items, granularity], + ); const listRef = useRef(null); const pillRef = useRef(null); const didInitOpenMonth = useRef(false); @@ -187,7 +234,9 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe {formatCurrency(group.income, group.currency)} - {showMetrics && } + {showMetrics && ( + + )} diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index dd9996e..1ff40da 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -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, }; } \ No newline at end of file -- 2.49.1 From 3837c9239e10e8b3b8d95b8297730988057dfb0f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 16:54:50 +0530 Subject: [PATCH 12/15] refactor(reports): drop txns_per_month and Count card Group strip is now Sum/Avg/Min/Max/Cadence-Frequency; count already shown in the accordion header, per-month rate derivable from monthly buckets. --- src/Reports/ReportViewer.tsx | 1 - src/Reports/types.ts | 6 ------ src/common/components/TransactionList.tsx | 11 ----------- src/common/utils/transactions.ts | 1 - 4 files changed, 19 deletions(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index a294eff..f744dba 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -162,7 +162,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re max: g.metrics.max, cadence: g.metrics.cadence, frequency: g.metrics.frequency, - txnsPerMonth: g.metrics.txnsPerMonth, }, })), [periodGroups], diff --git a/src/Reports/types.ts b/src/Reports/types.ts index fbda26d..8056f3f 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -12,7 +12,6 @@ export interface PeriodMetricsVM { lastDate: string | null; cadence: number | null; frequency: number | null; - txnsPerMonth: number | null; } export interface ReportPeriodGroup { @@ -109,7 +108,6 @@ export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPe sources: number; apiCadence: number | null; apiFrequency: number | null; - apiTxnsPerMonth: number | null; } const byPeriod = new Map(); const seenTxnIds = new Set(); @@ -135,13 +133,11 @@ export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPe 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); } @@ -192,11 +188,9 @@ export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPe 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 }; }) diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index 072c867..b242c11 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -48,7 +48,6 @@ function txnFallbackMetrics(items: ExpenseItem[]): ListGroupMetrics { max: t.max, cadence: t.cadenceDays, frequency: t.frequency, - txnsPerMonth: null, }; } @@ -67,18 +66,8 @@ function GroupMetrics({ { 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: "Count", - value: - 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 ( {rows.map((row) => ( diff --git a/src/common/utils/transactions.ts b/src/common/utils/transactions.ts index 1ff40da..2488f0d 100644 --- a/src/common/utils/transactions.ts +++ b/src/common/utils/transactions.ts @@ -49,7 +49,6 @@ export interface ListGroupMetrics { max: number | null; cadence: number | null; frequency: number | null; - txnsPerMonth: number | null; } export interface ListPeriodGroup extends PeriodGroup { -- 2.49.1 From c04c8fa00f0edb59b801550dea8746280204dab3 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 18:36:03 +0530 Subject: [PATCH 13/15] feat(reports): dimension-switchable bar strip (period/payees/tags) Segmented pill control with sliding indicator; pills disabled when the report has <=1 distinct value for that dim. Bars show top 10 by |sum| in a fixed-height view paged by chevrons (hidden scrollbar); widths are magnitude-based so all-outflow slices render correctly. --- src/Reports/ReportViewer.tsx | 216 +++++++++++++++++++++++++++++------ src/Reports/types.ts | 44 +++++++ 2 files changed, 228 insertions(+), 32 deletions(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index f744dba..ed165f5 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -1,14 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import CachedIcon from "@mui/icons-material/Cached"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi"; 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 type { ListPeriodGroup } from "../common/utils/transactions"; -import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import type { ReportDim } from "./types"; + +const MAX_VISIBLE_BARS = 10; const periodField: FieldConfig = { name: "period", @@ -78,6 +83,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const [reload, setReload] = useState(0); const [granularity, setGranularity] = useState(null); const [flow, setFlow] = useState("outflows"); + const [dim, setDim] = useState("period"); const [selectedPeriods, setSelectedPeriods] = useState([]); const [selectedPayees, setSelectedPayees] = useState([]); const [selectedTags, setSelectedTags] = useState([]); @@ -145,6 +151,8 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]); const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]); + const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]); + const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]); const listGroups = useMemo( () => periodGroups.map((g) => ({ @@ -167,6 +175,32 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re [periodGroups], ); + const barsListRef = useRef(null); + const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false }); + + const updateBarScroll = useCallback(() => { + const el = barsListRef.current; + if (!el) return; + const overflow = el.scrollHeight > el.clientHeight + 2; + setBarScroll({ + overflow, + canUp: overflow && el.scrollTop > 2, + canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2, + }); + }, []); + + useEffect(() => { + const el = barsListRef.current; + if (el) el.scrollTop = 0; + updateBarScroll(); + }, [dim, visibleBars.length, updateBarScroll]); + + const scrollBarsByPage = useCallback((dir: number) => { + const el = barsListRef.current; + if (!el) return; + el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" }); + }, []); + if (loading && !report) { return ( @@ -193,7 +227,16 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re if (!report) return null; const activeGranularity = granularity ?? report.granularities?.[0] ?? ""; - const maxBar = periodGroups.reduce((m, g) => Math.max(m, g.metrics.sum), 0); + const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0); + const dimOptions: { id: ReportDim; label: string; count: number }[] = [ + { id: "period", label: "Period", count: report.period_ids?.length ?? 0 }, + { id: "payee", label: "Payees", count: report.payees?.length ?? 0 }, + { id: "tag", label: "Tags", count: report.tags?.length ?? 0 }, + ]; + const activeDimIndex = Math.max( + 0, + dimOptions.findIndex((d) => d.id === dim), + ); const range = report.query?.start_date || report.query?.end_date ? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}` @@ -303,39 +346,148 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re - {periodGroups.length === 0 ? ( - - - No data for this slice. Try another granularity, period or payer. - - - ) : ( - - {periodGroups.map((g) => ( - - - {g.key} - + + + + {dimOptions.map((d) => { + const active = d.id === dim; + return ( setDim(d.id)} sx={{ - height: 20, - borderRadius: 1, - bgcolor: flow === "outflows" ? "error.main" : "success.main", - opacity: 0.85, - minWidth: 4, + position: "relative", + zIndex: 1, + flex: 1, + border: "none", + background: "transparent", + px: 3, + py: 1, + borderRadius: "999px", + cursor: d.count <= 1 ? "default" : "pointer", + typography: "body2", + fontWeight: 700, + letterSpacing: "-0.01em", + color: + d.count <= 1 + ? "text.disabled" + : active + ? "primary.contrastText" + : "text.secondary", + whiteSpace: "nowrap", + transition: "color 160ms ease", + "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" }, }} - style={{ width: `${maxBar ? Math.max((g.metrics.sum / maxBar) * 100, 2) : 2}%` }} - /> - - {formatCurrency(g.metrics.sum, slice.currency)} - - - {g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"} - + > + {d.label} + + ); + })} + + + {visibleBars.length === 0 ? ( + + + No data for this slice. Try another granularity, period or payer. + + + ) : ( + + + + {visibleBars.map((b) => ( + + + {b.key} + + + + {formatCurrency(b.sum, slice.currency)} + + + {b.count} txn{b.count === 1 ? "" : "s"} + + + ))} + + {barScroll.overflow && ( + + scrollBarsByPage(-1)} + > + + + scrollBarsByPage(1)} + > + + + + )} - ))} - - )} + + )} + {slice.txns.length === 0 ? null : fields ? ( diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 8056f3f..7d07808 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -231,4 +231,48 @@ export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFiel const generatedAt = find("generated_at"); if (!name || !generatedAt) return null; return { name, generatedAt }; +} + +export type ReportDim = "period" | "payee" | "tag"; + +export interface DimensionBar { + key: string; + sum: number; + count: number; +} + +/** + * Bar-chart rows for a given dimension. "period" reuses the period groups; + * "payee"/"tag" fold each bucket's metrics into every value listed under + * group_key[dim] — safe because server buckets are disjoint slices. + */ +export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] { + if (dim === "period") { + return buildPeriodGroups(buckets, filter).map((g) => ({ + key: g.key, + sum: g.metrics.sum, + count: g.metrics.count, + })); + } + const acc = new Map(); + for (const bucket of buckets ?? []) { + if (!bucketMatches(bucket, filter)) continue; + const keys: string[] = bucket.group_key?.[dim] ?? []; + for (const period of bucket.series?.[filter.granularity] ?? []) { + if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; + const m = period.metrics ?? {}; + const sum = typeof m.sum === "number" ? m.sum : 0; + const count = typeof m.count === "number" ? m.count : 0; + for (const k of keys) { + const cur = acc.get(k); + if (cur) { + cur.sum += sum; + cur.count += count; + } else { + acc.set(k, { key: k, sum, count }); + } + } + } + } + return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum)); } \ No newline at end of file -- 2.49.1 From 137365b50186d36361adb4a2af2038c9b1e6f489 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 19:15:46 +0530 Subject: [PATCH 14/15] feat(reports): request full breakdown for active dim via wildcard When the active bar dimension's own filter is unselected, the viewer sends payee=*/tags=* so bars show the whole distribution; explicit selections still slice normally. --- src/Reports/ReportViewer.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index ed165f5..ad151f2 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -93,10 +93,14 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const p: Record = { flow }; if (granularity) p.granularity = [granularity]; if (selectedPeriods.length) p.period_ids = selectedPeriods; + // The active breakdown dim requests every cube value ("*") when its own + // filter is unselected, so bars show the full distribution. if (selectedPayees.length) p.payee = selectedPayees; + else if (dim === "payee") p.payee = ["*"]; if (selectedTags.length) p.tags = selectedTags; + else if (dim === "tag") p.tags = ["*"]; return p; - }, [granularity, flow, selectedPeriods, selectedPayees, selectedTags]); + }, [granularity, flow, dim, selectedPeriods, selectedPayees, selectedTags]); useEffect(() => { let mounted = true; -- 2.49.1 From d56f3b0f9706c736aeedba8fa57d6bc92677c952 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 21 Aug 2026 19:35:15 +0530 Subject: [PATCH 15/15] fix(reports): parse DD-MM-YYYY dates strictly in period merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new Date() mis-read DD-MM-YYYY as MM-DD-YYYY, so multi-bucket periods (e.g. merged inflow+outflow slices) re-derived cadence from garbage dates — Aug Zomato showed 12.89 days instead of 0.76. Use the strict parseOccurredAt() in the cadence branch and dateVal() so first/last date merges order correctly too. --- src/Reports/types.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 7d07808..6873573 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -1,4 +1,5 @@ import type { FieldConfig, ResourceConfig } from "../../react-openapi"; +import { parseOccurredAt } from "../common/utils/dates"; export interface PeriodMetricsVM { outflows: number; @@ -79,8 +80,11 @@ export function metricLabels(schemas: Record): MetricLabel[] { } function dateVal(value: string): number { - const t = new Date(value).getTime(); - return Number.isNaN(t) ? 0 : t; + try { + return parseOccurredAt(value).getTime(); + } catch { + return 0; + } } function bucketMatches(bucket: any, filter: SliceFilter): boolean { @@ -175,7 +179,13 @@ export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPe 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()) + .map((t) => { + try { + return parseOccurredAt(t?.occurred_at).getTime(); + } catch { + return NaN; + } + }) .filter((t) => !Number.isNaN(t)) .sort((a, b) => a - b); if (dates.length >= 2) { -- 2.49.1