major refactor of the dashboard and react-openapi integration #1

Merged
aetos merged 44 commits from period-selection into main 2026-05-07 11:00:54 +00:00
7 changed files with 244 additions and 345 deletions
Showing only changes of commit 1fe44abfde - Show all commits

View File

@@ -0,0 +1,9 @@
import { api } from "../../../react-openapi";
export async function fetchReport(params: {
period: "weekly" | "monthly" | "yearly" | "fyly";
rolling?: boolean;
}) {
const res = await api.get("/reports", { params });
return res.data;
}

View File

@@ -0,0 +1,96 @@
import {
AggregatedDashboardData,
ChartData,
ChartDataPoint,
} from "../../components/HistoryChart";
type ReportBucket = any;
const sumBucket = (bucket: ReportBucket, flow: "expenses" | "incomes") =>
bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
const toLabel = (start: string, end: string, type: "weekly" | "monthly") => {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
return `${s.getDate()}${e.getDate()} ${e.toLocaleString("default", {
month: "short",
})}`;
};
const toPoints = (
buckets: ReportBucket[],
type: "weekly" | "monthly",
flow: "expenses" | "incomes"
): ChartDataPoint[] => {
return buckets.map((b, i) => {
const amount = sumBucket(b, flow);
const prev = buckets[i - 1];
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: sumBucket(prev, flow),
}
: undefined,
};
});
};
export function mapReportToDashboard(
weekly: ReportBucket[],
monthly: ReportBucket[],
type: "expense" | "income"
): AggregatedDashboardData {
const flow = type === "expense" ? "expenses" : "incomes";
const chartData: ChartData = {
daily: [],
weekly: {
rolling: toPoints(weekly, "weekly", flow),
calendar: toPoints(weekly, "weekly", flow),
},
monthly: {
rolling: toPoints(monthly, "monthly", flow),
calendar: toPoints(monthly, "monthly", flow),
},
};
const totalAmount = weekly.reduce(
(acc, b) => acc + sumBucket(b, flow),
0
);
const payeeMap: Record<string, number> = {};
for (const b of weekly) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
payeeMap[key] = (payeeMap[key] || 0) + amt;
}
}
const topPayees = Object.entries(payeeMap)
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -0,0 +1,114 @@
import { fetchReport } from "./report.api";
import {
AggregatedDashboardData,
ChartData,
ChartDataPoint,
} from "../components/HistoryChart";
type ReportBucket = any; // replace with generated type if available
function sumBucket(bucket: ReportBucket, flow: "expenses" | "incomes") {
return bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
}
function toLabel(start: string, end: string, type: "weekly" | "monthly") {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
const sd = s.getDate();
const ed = e.getDate();
const m = e.toLocaleString("default", { month: "short" });
return `${sd}${ed} ${m}`;
}
function toChartPoints(
buckets: ReportBucket[],
type: "weekly" | "monthly",
flow: "expenses" | "incomes"
): ChartDataPoint[] {
return buckets.map((b, i) => {
const amount = sumBucket(b, flow);
const prev = buckets[i - 1];
const compareAmount = prev ? sumBucket(prev, flow) : 0;
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: compareAmount,
}
: undefined,
};
});
}
function buildChartData(
weekly: ReportBucket[],
monthly: ReportBucket[],
flow: "expenses" | "incomes"
): ChartData {
return {
daily: [], // not supported by /reports → keep empty or drop
weekly: {
rolling: toChartPoints(weekly, "weekly", flow),
calendar: toChartPoints(weekly, "weekly", flow), // same unless backend differentiates
},
monthly: {
rolling: toChartPoints(monthly, "monthly", flow),
calendar: toChartPoints(monthly, "monthly", flow),
},
};
}
function getTopPayees(buckets: ReportBucket[], flow: "expenses" | "incomes") {
const map: Record<string, number> = {};
for (const b of buckets) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
map[key] = (map[key] || 0) + amt;
}
}
return Object.entries(map)
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
}
export async function getDashboardData(
type: "expense" | "income"
): Promise<AggregatedDashboardData> {
const flow = type === "expense" ? "expenses" : "incomes";
const [weeklyBuckets, monthlyBuckets] = await Promise.all([
fetchReport({ period: "weekly", rolling: true }),
fetchReport({ period: "monthly", rolling: true }),
]);
const chartData = buildChartData(weeklyBuckets, monthlyBuckets, flow);
const totalAmount = weeklyBuckets.reduce(
(acc: number, b: any) => acc + sumBucket(b, flow),
0
);
const topPayees = getTopPayees(weeklyBuckets, flow);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { fetchReport } from "./report.api";
export interface ReportParams {
period: "weekly" | "monthly" | "yearly" | "fyly";
rolling?: boolean;
report_date?: string;
group_by?: ("flow" | "payee" | "tags")[];
ignore_self?: boolean;
}
export function useReport(params: ReportParams) {
return useQuery({
queryKey: ["report", params],
queryFn: () => fetchReport(params),
});
}

View File

@@ -1,17 +1,10 @@
import { api } from "../../react-openapi"; import { api } from "../../react-openapi";
import { LatestItem } from "../components/LatestItemsList"; import { LatestItem } from "../components/LatestItems";
import { ChartDataPoint } from "../types/historyChart";
import * as React from "react"; import * as React from "react";
import { format } from "./dateUtils";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn"; import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { import { fetchReport } from "../features/report/report.api";
buildDailyBuckets, import { mapReportToDashboard } from "../features/report/report.mapper";
buildWeeklyRolling,
buildWeeklyCalendar,
buildMonthlyRolling,
buildMonthlyCalendar
} from "./periodBuilders";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" } sx: { color: "#388e3c" }
@@ -58,131 +51,12 @@ export async function fetchLatestTransactions(
export async function fetchAggregatedData( export async function fetchAggregatedData(
type: "expense" | "income" type: "expense" | "income"
) { ) {
const res = await api.get("/expenses", { params: { limit: 0 } }); const [weekly, monthly] = await Promise.all([
const all: any[] = res.data?.items || res.data || []; fetchReport({ period: "weekly", rolling: true }),
fetchReport({ period: "monthly", rolling: true }),
]);
const now = new Date(); return mapReportToDashboard(weekly.buckets, monthly.buckets, type);
let totalAmount = 0;
const payeeMap: Record<string, number> = {};
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
const normalize = (amt: number) => Math.abs(amt);
const {
buckets: dailyBuckets,
weekStart,
weekEnd,
prevWeekStart,
prevWeekEnd
} = buildDailyBuckets(now);
const weeklyRolling = buildWeeklyRolling(now);
const weeklyCalendar = buildWeeklyCalendar(now);
const monthlyRolling = buildMonthlyRolling(now);
const monthlyCalendar = buildMonthlyCalendar(now);
for (const item of all) {
const d = new Date(
item.occurred_at || item.created_at || Date.now()
);
const amtRaw = Number(item.amount) || 0;
if (!isValid(amtRaw)) continue;
const amt = normalize(amtRaw);
totalAmount += amt;
const payee = item.payee?.name || item.payee || "Unknown";
payeeMap[payee] = (payeeMap[payee] || 0) + amt;
const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (d >= weekStart && d <= weekEnd) {
if (dailyBuckets[day]) {
dailyBuckets[day].amount += amt;
}
}
if (d >= prevWeekStart && d <= prevWeekEnd) {
if (dailyBuckets[day]) {
dailyBuckets[day].compare += amt;
}
}
const apply = (arr: any[]) => {
for (const b of arr) {
if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd)
b.compare += amt;
}
};
apply(weeklyRolling);
apply(weeklyCalendar);
apply(monthlyRolling);
apply(monthlyCalendar);
}
const toPoints = (arr: any[], type: "weekly" | "monthly"): ChartDataPoint[] =>
arr.map((x) => {
let compareLabel: string | undefined;
if (x.prevStart && x.prevEnd) {
if (type === "monthly") {
const year = String(x.prevStart.getFullYear()).slice(2);
compareLabel = `${x.prevStart.toLocaleString("default", {
month: "short"
})}-${year}`;
} else {
const year = String(x.prevEnd.getFullYear()).slice(2);
compareLabel = `${format(x.prevStart)} - ${format(x.prevEnd)} ${year}`;
}
}
return {
id: x.label,
amount: x.amount,
compareAmount: x.compare,
compareLabel
};
});
const chartData = {
daily: Object.entries(dailyBuckets).map(([k, v]: any) => ({
id: k,
amount: v.amount,
compareAmount: v.compare
})),
weekly: {
rolling: toPoints(weeklyRolling, "weekly"),
calendar: toPoints(weeklyCalendar, "weekly")
},
monthly: {
rolling: toPoints(monthlyRolling, "monthly"),
calendar: toPoints(monthlyCalendar, "monthly")
}
};
Object.values(chartData).forEach((group: any) => {
const arr = Array.isArray(group) ? group : group.rolling;
if (!arr?.length) return;
let max = arr[0];
for (const g of arr) {
if (g.amount > max.amount) max = g;
}
if (max.amount > 0) max.highlighted = true;
});
const topPayees = Object.entries(payeeMap)
.map(([name, amt]) => ({ payeeName: name, amount: amt }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return { chartData, totalAmount, topPayees };
} }
export const fetchAggregatedExpenses = () => export const fetchAggregatedExpenses = () =>

View File

@@ -1,33 +0,0 @@
export const format = (d: Date) =>
`${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`;
export const startOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
};
export const endOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
};
export const getStartOfWeek = (d: Date) => {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
return startOfDay(date);
};
export const shiftDate = (d: Date, days: number) =>
new Date(d.getTime() + days * 86400000);
export const getWeekIndex = (date: Date) => {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
const firstWeekStart = getStartOfWeek(firstDay);
return Math.floor(
(startOfDay(date).getTime() - firstWeekStart.getTime()) /
(7 * 86400000)
);
};

View File

@@ -1,178 +0,0 @@
import {
format,
endOfDay,
getStartOfWeek,
shiftDate,
getWeekIndex
} from "./dateUtils";
export const buildDailyBuckets = (now: Date) => {
const buckets: Record<string, any> = {
Mon: { amount: 0, compare: 0 },
Tue: { amount: 0, compare: 0 },
Wed: { amount: 0, compare: 0 },
Thu: { amount: 0, compare: 0 },
Fri: { amount: 0, compare: 0 },
Sat: { amount: 0, compare: 0 },
Sun: { amount: 0, compare: 0 }
};
const weekStart = getStartOfWeek(now);
const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000));
const prevWeekStart = shiftDate(weekStart, -7);
const prevWeekEnd = shiftDate(weekEnd, -7);
return { buckets, weekStart, weekEnd, prevWeekStart, prevWeekEnd };
};
const getPrevMonthWeek = (start: Date) => {
const prevMonthDate = new Date(start);
prevMonthDate.setMonth(prevMonthDate.getMonth() - 1);
const prevMonthFirst = new Date(
prevMonthDate.getFullYear(),
prevMonthDate.getMonth(),
1
);
const prevFirstWeekStart = getStartOfWeek(prevMonthFirst);
const weekIndex = getWeekIndex(start);
const prevStart = new Date(
prevFirstWeekStart.getTime() + weekIndex * 7 * 86400000
);
const prevEnd = endOfDay(new Date(prevStart.getTime() + 6 * 86400000));
return { prevStart, prevEnd };
};
export const buildWeeklyRolling = (now: Date) => {
const arr: any[] = [];
const currentWeekStart = getStartOfWeek(now);
for (let i = 4; i >= 0; i--) {
const start = new Date(
currentWeekStart.getTime() - i * 7 * 86400000
);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
const { prevStart, prevEnd } = getPrevMonthWeek(start);
arr.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildWeeklyCalendar = (now: Date) => {
const arr: any[] = [];
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const firstWeekStart = getStartOfWeek(startOfMonth);
const totalWeeks =
Math.ceil(
(endOfMonth.getTime() - firstWeekStart.getTime()) /
(7 * 86400000)
) + 1;
for (let i = 0; i < totalWeeks; i++) {
const start = new Date(
firstWeekStart.getTime() + i * 7 * 86400000
);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
const { prevStart, prevEnd } = getPrevMonthWeek(start);
arr.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildMonthlyRolling = (now: Date) => {
const arr: any[] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now);
d.setMonth(d.getMonth() - i);
const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end =
i === 0
? endOfDay(now)
: endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
let prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
if (i === 0) {
prevEnd = new Date(prevStart);
prevEnd.setDate(now.getDate());
prevEnd = endOfDay(prevEnd);
}
arr.push({
label: `${d.toLocaleString("default", {
month: "short"
})}-${String(d.getFullYear()).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildMonthlyCalendar = (now: Date) => {
const arr: any[] = [];
for (let i = 0; i < 12; i++) {
const start = new Date(now.getFullYear(), i, 1);
const end = endOfDay(new Date(now.getFullYear(), i + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
const prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
arr.push({
label: `${start.toLocaleString("default", {
month: "short"
})}-${String(start.getFullYear()).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};