From 47c00a06a88d51d60e3cf51b8327b51f19006148 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 14:37:52 +0530 Subject: [PATCH] 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