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