fix expenses grouping to parse occurred_at as DD-MM-YYYY strictly

occurred_at comes from the backend as a DD-MM-YYYY string, which
new Date() cannot parse (NaN), so most transactions fell into an
"unknown" bucket and month sorting was broken.

- add parseOccurredAt() that only accepts DD-MM-YYYY, day-first,
  and throws on any other format or invalid date
- monthKey() now throws instead of returning "unknown"
- add currentMonthKey() for the "this month" stat
- sort groupByMonth/summary by parsed timestamp
This commit is contained in:
2026-08-17 16:43:46 +05:30
parent 3dd833ac2b
commit e8c585bdb8
3 changed files with 30 additions and 10 deletions

View File

@@ -14,7 +14,7 @@ import { useResource, useAppContext, formatCurrency } from "../../react-openapi"
import { PageHeader } from "../ui/PageHeader"; import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState"; import { EmptyState } from "../ui/EmptyState";
import { ExpenseList } from "./ExpenseList"; import { ExpenseList } from "./ExpenseList";
import { ExpenseItem, ExpenseFieldConfigs, isExpense, monthKey, monthLabel, resolveLogoUrl } from "./types"; import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt, resolveLogoUrl } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL; const API_BASE = import.meta.env.VITE_API_BASE_URL;
@@ -94,7 +94,7 @@ export default function Expense() {
const sorted = useMemo( const sorted = useMemo(
() => () =>
[...(items ?? [])].sort( [...(items ?? [])].sort(
(a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
), ),
[items], [items],
); );
@@ -103,8 +103,7 @@ export default function Expense() {
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0); const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0);
const now = new Date(); const thisMonth = currentMonthKey();
const thisMonth = monthKey(now.toISOString());
const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth); const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth);
const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal }; return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal };

View File

@@ -9,7 +9,7 @@ import {
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
import { monthKey, monthLabel } from "./types"; import { monthKey, monthLabel, parseOccurredAt } from "./types";
import { ExpenseDetail } from "./ExpenseDetail"; import { ExpenseDetail } from "./ExpenseDetail";
interface GroupedMonth { interface GroupedMonth {
@@ -30,7 +30,7 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
return [...map.entries()] return [...map.entries()]
.map(([key, list]) => { .map(([key, list]) => {
const sorted = [...list].sort( const sorted = [...list].sort(
(a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
); );
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0); const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0);

View File

@@ -36,13 +36,34 @@ export function resolveLogoUrl(logo?: string, base?: string): string | undefined
return logo; return logo;
} }
export function monthKey(iso?: string): string { export function parseOccurredAt(value?: string): Date {
if (!iso) return "unknown"; const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/);
const d = new Date(iso); if (!m) {
if (Number.isNaN(d.getTime())) return "unknown"; throw new Error(`Expense occurred_at 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 expense occurred_at date: ${value}`);
}
return d;
}
export function monthKey(value?: string): string {
const d = parseOccurredAt(value);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
} }
export function currentMonthKey(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export function monthLabel(key: string): string { export function monthLabel(key: string): string {
const [y, m] = key.split("-").map(Number); const [y, m] = key.split("-").map(Number);
if (!y || !m) return key; if (!y || !m) return key;