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.
This commit is contained in:
@@ -22,6 +22,7 @@ import { useAppContext, useResource, formatCurrency } from "../../react-openapi"
|
|||||||
import { StatCard } from "../common/components/StatCard";
|
import { StatCard } from "../common/components/StatCard";
|
||||||
import { TransactionList } from "../common/components/TransactionList";
|
import { TransactionList } from "../common/components/TransactionList";
|
||||||
import type { TxnFieldConfigs } from "../common/types";
|
import type { TxnFieldConfigs } from "../common/types";
|
||||||
|
import { toPeriodGranularity } from "../common/utils/transactions";
|
||||||
import { aggregateSlice, buildPivot, metricLabels } from "./types";
|
import { aggregateSlice, buildPivot, metricLabels } from "./types";
|
||||||
|
|
||||||
interface ReportViewerProps {
|
interface ReportViewerProps {
|
||||||
@@ -281,7 +282,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : fields ? (
|
) : fields ? (
|
||||||
<TransactionList items={slice.txns} fields={fields} />
|
<TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} />
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,20 +14,21 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|||||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||||
import { formatCurrency } from "../../../react-openapi";
|
import { formatCurrency } from "../../../react-openapi";
|
||||||
import type { ExpenseItem, TxnFieldConfigs } from "../types";
|
import type { ExpenseItem, TxnFieldConfigs } from "../types";
|
||||||
import { groupByDate, groupByMonth } from "../utils/transactions";
|
import { groupByDate, groupByPeriod } from "../utils/transactions";
|
||||||
import { monthLabel } from "../utils/dates";
|
import type { PeriodGranularity } from "../utils/transactions";
|
||||||
import { TransactionRow } from "./TransactionRow";
|
import { TransactionRow } from "./TransactionRow";
|
||||||
|
|
||||||
interface TransactionListProps {
|
interface TransactionListProps {
|
||||||
items: ExpenseItem[];
|
items: ExpenseItem[];
|
||||||
fields: TxnFieldConfigs;
|
fields: TxnFieldConfigs;
|
||||||
|
granularity?: PeriodGranularity;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransactionList({ items, fields }: TransactionListProps) {
|
export function TransactionList({ items, fields, granularity = "monthly" }: TransactionListProps) {
|
||||||
const [activeMonth, setActiveMonth] = useState<string | null>(null);
|
const [activeMonth, setActiveMonth] = useState<string | null>(null);
|
||||||
const [openMonth, setOpenMonth] = useState<string | null>(null);
|
const [openMonth, setOpenMonth] = useState<string | null>(null);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
|
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
|
||||||
const groups = useMemo(() => groupByMonth(items), [items]);
|
const groups = useMemo(() => groupByPeriod(items, granularity), [items, granularity]);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const pillRef = useRef<HTMLDivElement>(null);
|
const pillRef = useRef<HTMLDivElement>(null);
|
||||||
const didInitOpenMonth = useRef(false);
|
const didInitOpenMonth = useRef(false);
|
||||||
@@ -142,7 +143,7 @@ export function TransactionList({ items, fields }: TransactionListProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
||||||
{monthLabel(group.key)}
|
{group.label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
||||||
@@ -256,7 +257,7 @@ export function TransactionList({ items, fields }: TransactionListProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
{activeMonth ? monthLabel(activeMonth) : ""}
|
{activeMonth ? (groups.find((g) => g.key === activeMonth)?.label ?? activeMonth) : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: "text.secondary" }} />
|
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: "text.secondary" }} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -276,7 +277,7 @@ export function TransactionList({ items, fields }: TransactionListProps) {
|
|||||||
onClick={() => handleSelectMonth(group.key)}
|
onClick={() => handleSelectMonth(group.key)}
|
||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={monthLabel(group.key)}
|
primary={group.label}
|
||||||
secondary={`${group.items.length} transactions`}
|
secondary={`${group.items.length} transactions`}
|
||||||
/>
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -11,14 +11,6 @@ export interface ExpenseItem {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupedMonth {
|
|
||||||
key: string;
|
|
||||||
items: ExpenseItem[];
|
|
||||||
spent: number;
|
|
||||||
income: number;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TxnFieldConfigs {
|
export interface TxnFieldConfigs {
|
||||||
entity: FieldConfig;
|
entity: FieldConfig;
|
||||||
amount: FieldConfig;
|
amount: FieldConfig;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ExpenseItem, GroupedMonth } from "../types";
|
import type { ExpenseItem } from "../types";
|
||||||
import { dateLabel, monthKey, parseOccurredAt } from "./dates";
|
import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates";
|
||||||
|
|
||||||
export function isExpense(item: ExpenseItem): boolean {
|
export function isExpense(item: ExpenseItem): boolean {
|
||||||
return (item.amount ?? 0) < 0;
|
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<string, ExpenseItem[]>();
|
const map = new Map<string, ExpenseItem[]>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = monthKey(item.occurred_at);
|
const key = periodKey(item.occurred_at, granularity);
|
||||||
const list = map.get(key) ?? [];
|
const list = map.get(key) ?? [];
|
||||||
list.push(item);
|
list.push(item);
|
||||||
map.set(key, list);
|
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 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 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);
|
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));
|
.sort((a, b) => b.key.localeCompare(a.key));
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user