12 Commits

Author SHA1 Message Date
3837c9239e 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.
2026-08-21 16:54:50 +05:30
d979e443be refactor(reports): render API metrics verbatim via period groups 2026-08-21 16:45:47 +05:30
e8faafae7d 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.
2026-08-21 15:45:23 +05:30
6b340d89f6 refactor: revamp for older report logic with benefits of newer one 2026-08-20 19:52:43 +05:30
79808d6d3e 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.
2026-08-20 17:10:48 +05:30
f865f7dec2 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.
2026-08-20 16:37:05 +05:30
fc3a48a367 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.
2026-08-20 16:20:06 +05:30
f9759e3968 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.
2026-08-20 16:03:12 +05:30
47c00a06a8 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.
2026-08-20 14:37:52 +05:30
a107029b90 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.
2026-08-20 14:14:44 +05:30
baa9b296cb 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.
2026-08-20 13:29:44 +05:30
f08bc72037 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
2026-08-19 22:00:14 +05:30
7 changed files with 41 additions and 341 deletions

1
.gitignore vendored
View File

@@ -14,4 +14,3 @@ dist
dist-ssr
*.local
.idea
tsconfig.tsbuildinfo

View File

@@ -1,32 +1,22 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box, Container, Typography, Paper, Button, Alert,
CircularProgress, IconButton, Tooltip,
CircularProgress,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { formatApiError } from "../features/fetch-requests";
import { useToast } from "../ui/Toast";
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
function FetchRequestList() {
const navigate = useNavigate();
const { list, remove, resource } = useResource("fetch-requests");
const { list, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { showToast } = useToast();
const reload = () => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
};
const columns = useMemo(() => {
if (!resource) return [];
@@ -40,26 +30,6 @@ function FetchRequestList() {
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]);
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeletingId(id);
try {
await remove(id);
showToast("Fetch request deleted");
reload();
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeletingId(null);
}
};
if (!rows) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
@@ -112,18 +82,6 @@ function FetchRequestList() {
/>
</Box>
))}
<Box sx={{ ml: "auto", flexShrink: 0 }}>
<Tooltip title="Delete fetch request">
<IconButton
size="small"
color="error"
disabled={deletingId === row.id || row.status === "processing"}
onClick={(e) => handleDelete(e, row.id)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
@@ -136,7 +94,6 @@ export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests");
const navigate = useNavigate();
const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false);
@@ -186,10 +143,6 @@ export default function FetchRequestCreate() {
const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` });
setFormData({});
const newId = (created as any)?.id;
if (newId) {
navigate(`/fetch-requests/${newId}`);
}
} catch (e: any) {
const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");

View File

@@ -10,7 +10,6 @@ import {
CircularProgress,
Alert,
Divider,
Tooltip,
} from "@mui/material";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
@@ -19,7 +18,6 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
@@ -87,12 +85,11 @@ function Section({ title, children, action }: { title: string; children: React.R
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, patch, remove, resource } = useResource("fetch-requests");
const { get, patch, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [deleting, setDeleting] = useState(false);
const { showToast } = useToast();
const feedRef = useRef<HTMLDivElement>(null);
@@ -177,26 +174,6 @@ export default function FetchRequestDetail() {
}
};
const handleDelete = async () => {
if (!id || !remove || deleting) return;
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeleting(true);
try {
await remove(id);
showToast("Fetch request deleted");
navigate("/fetch-requests");
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeleting(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
@@ -309,20 +286,6 @@ export default function FetchRequestDetail() {
Retry
</Button>
)}
<Tooltip title={status === "processing" ? "Cannot delete while processing" : "Delete fetch request"}>
<span>
<Button
variant="outlined"
size="small"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDelete}
disabled={deleting || status === "processing"}
>
Delete
</Button>
</span>
</Tooltip>
</Box>
</Section>

View File

@@ -29,12 +29,7 @@ function computeProgressPercent(
let pct = 0;
if (
seenSteps.has("raw_lines") ||
seenSteps.has("txn_blocks")
) {
pct += 10;
}
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);

View File

@@ -1,19 +1,14 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { 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 { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
import type { ReportDim } from "./types";
const MAX_VISIBLE_BARS = 10;
import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
const periodField: FieldConfig = {
name: "period",
@@ -83,7 +78,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
const [reload, setReload] = useState(0);
const [granularity, setGranularity] = useState<string | null>(null);
const [flow, setFlow] = useState("outflows");
const [dim, setDim] = useState<ReportDim>("period");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@@ -93,14 +87,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
const p: Record<string, any> = { 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, dim, selectedPeriods, selectedPayees, selectedTags]);
}, [granularity, flow, selectedPeriods, selectedPayees, selectedTags]);
useEffect(() => {
let mounted = true;
@@ -155,8 +145,6 @@ 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<ListPeriodGroup[]>(
() =>
periodGroups.map((g) => ({
@@ -179,32 +167,6 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
[periodGroups],
);
const barsListRef = useRef<HTMLDivElement>(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 (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
@@ -231,16 +193,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, 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 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 || "…"}`
@@ -350,148 +303,39 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
<Box sx={{ mb: 2.5 }}>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "stretch",
width: "fit-content",
maxWidth: "100%",
border: "1px solid",
borderColor: "divider",
borderRadius: "999px",
p: 0.5,
backgroundColor: "background.default",
}}
>
<Box
aria-hidden
sx={{
position: "absolute",
top: 4,
bottom: 4,
left: 4,
width: `calc((100% - 8px) / ${dimOptions.length})`,
borderRadius: "999px",
backgroundColor: "primary.main",
transform: `translateX(${activeDimIndex * 100}%)`,
transition: "transform 250ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
{dimOptions.map((d) => {
const active = d.id === dim;
return (
{periodGroups.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
{periodGroups.map((g) => (
<Box key={g.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{g.key}
</Typography>
<Box
key={d.id}
component="button"
type="button"
disabled={d.count <= 1}
onClick={() => setDim(d.id)}
sx={{
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" },
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
}}
>
{d.label}
</Box>
);
})}
</Box>
{visibleBars.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mt: 1.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, mt: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "stretch" }}>
<Box
ref={barsListRef}
onScroll={updateBarScroll}
sx={{
flex: 1,
minWidth: 0,
maxHeight: 220,
overflowY: "auto",
py: 1,
px: 2,
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{visibleBars.map((b) => (
<Box key={b.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.key}
</Typography>
<Box
sx={{
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
flexGrow: 0,
}}
style={{
width: `${maxBar ? Math.max((Math.abs(b.sum) / maxBar) * 100, 2) : 2}%`,
}}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(b.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{b.count} txn{b.count === 1 ? "" : "s"}
</Typography>
</Box>
))}
</Box>
{barScroll.overflow && (
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 0.5, pr: 1.5 }}>
<IconButton
size="small"
aria-label="Scroll bars up"
disabled={!barScroll.canUp}
onClick={() => scrollBarsByPage(-1)}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Scroll bars down"
disabled={!barScroll.canDown}
onClick={() => scrollBarsByPage(1)}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Box>
)}
style={{ width: `${maxBar ? Math.max((g.metrics.sum / maxBar) * 100, 2) : 2}%` }}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(g.metrics.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"}
</Typography>
</Box>
</Paper>
)}
</Box>
))}
</Paper>
)}
{slice.txns.length === 0 ? null : fields ? (
<TransactionList fields={fields} groups={listGroups} showMetrics />

View File

@@ -1,5 +1,4 @@
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
import { parseOccurredAt } from "../common/utils/dates";
export interface PeriodMetricsVM {
outflows: number;
@@ -80,11 +79,8 @@ export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
}
function dateVal(value: string): number {
try {
return parseOccurredAt(value).getTime();
} catch {
return 0;
}
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
function bucketMatches(bucket: any, filter: SliceFilter): boolean {
@@ -179,13 +175,7 @@ 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) => {
try {
return parseOccurredAt(t?.occurred_at).getTime();
} catch {
return NaN;
}
})
.map((t) => new Date(t?.occurred_at ?? "").getTime())
.filter((t) => !Number.isNaN(t))
.sort((a, b) => a - b);
if (dates.length >= 2) {
@@ -241,48 +231,4 @@ 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<string, DimensionBar>();
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));
}

View File

@@ -26,7 +26,7 @@ export interface EmailSource {
txn_dicts_count?: number;
}
export type PipelineType = "heuristic" | "llm" | "llama_parse";
export type PipelineType = "heuristic" | "llm" | "llama_parser";
export interface FetchRequestCreate {
source: FileSource | EmailSource;