Adds an id-keyed hierarchical `tree: OverTimeRow[]` sidecar to getCategoryOverTime, built via the generic buildLeafDrivenTree (#263) as buildOverTimeTree — leaf-driven, income-first, no top-N / no "Other" bucket. The name-keyed pivot (data/categories/colors/types/categoryIds) is left BYTE-IDENTICAL, so the Trends chart and the dashboard render unchanged. computeOverTimeResults now consumes the id-keyed tree LEAVES (never the `is_parent` subtotals) grouped by each leaf's category_id-resolved type, so the section subtotals and the Result-before/net lines are exact: two homonym categories of different types no longer collide, and a non-top-N income or transfer category is no longer lumped into "Other" as an expense. CategoryOverTimeTable renders the tree leaves (every category, id-safe) instead of the top-N pivot; the chart keeps reading the top-N-capped pivot so its stacked series stay bounded. OverTimeRow mirrors CategoryDelta's snake_case hierarchy block (parent_id/is_parent/depth/category_type) so it composes with collapsibleRows / useCollapsibleGroups for the #265 hierarchy work. Tests: rewrote overTimeResults.test.ts onto the tree (homonym regression + leaves-only-not-parents); added buildOverTimeTree suite (nesting, income-first, grand-total invariance, orphan, A->B->A cycle depth guard) and a getCategoryOverTime tree-wiring test proving the pivot stays top-N+Other while the tree carries every category. 720 vitest green, build + tsc green. Resolves #264 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
5 KiB
TypeScript
143 lines
5 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type {
|
|
DashboardPeriod,
|
|
DashboardSummary,
|
|
CategoryBreakdownItem,
|
|
CategoryOverTimeData,
|
|
BudgetVsActualRow,
|
|
} from "../shared/types";
|
|
import {
|
|
getDashboardSummary,
|
|
getExpensesByCategory,
|
|
} from "../services/dashboardService";
|
|
import { getCategoryOverTime } from "../services/reportService";
|
|
import { getBudgetVsActualData } from "../services/budgetService";
|
|
import { computeDateRange } from "../utils/dateRange";
|
|
|
|
interface DashboardState {
|
|
summary: DashboardSummary;
|
|
categoryBreakdown: CategoryBreakdownItem[];
|
|
categoryOverTime: CategoryOverTimeData;
|
|
budgetVsActual: BudgetVsActualRow[];
|
|
period: DashboardPeriod;
|
|
budgetYear: number;
|
|
budgetMonth: number;
|
|
customDateFrom: string;
|
|
customDateTo: string;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type DashboardAction =
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_ERROR"; payload: string | null }
|
|
| {
|
|
type: "SET_DATA";
|
|
payload: {
|
|
summary: DashboardSummary;
|
|
categoryBreakdown: CategoryBreakdownItem[];
|
|
categoryOverTime: CategoryOverTimeData;
|
|
budgetVsActual: BudgetVsActualRow[];
|
|
};
|
|
}
|
|
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
|
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
|
|
|
|
const now = new Date();
|
|
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
const yearStartStr = `${now.getFullYear()}-01-01`;
|
|
|
|
const initialState: DashboardState = {
|
|
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
|
|
categoryBreakdown: [],
|
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [] },
|
|
budgetVsActual: [],
|
|
period: "year",
|
|
budgetYear: now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear(),
|
|
budgetMonth: now.getMonth() === 0 ? 12 : now.getMonth(),
|
|
customDateFrom: yearStartStr,
|
|
customDateTo: todayStr,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
|
|
function reducer(state: DashboardState, action: DashboardAction): DashboardState {
|
|
switch (action.type) {
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
case "SET_DATA":
|
|
return {
|
|
...state,
|
|
summary: action.payload.summary,
|
|
categoryBreakdown: action.payload.categoryBreakdown,
|
|
categoryOverTime: action.payload.categoryOverTime,
|
|
budgetVsActual: action.payload.budgetVsActual,
|
|
isLoading: false,
|
|
};
|
|
case "SET_PERIOD":
|
|
return { ...state, period: action.payload };
|
|
case "SET_BUDGET_MONTH":
|
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
|
case "SET_CUSTOM_DATES":
|
|
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useDashboard() {
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetchData = useCallback(async (
|
|
period: DashboardPeriod,
|
|
customFrom: string | undefined,
|
|
customTo: string | undefined,
|
|
bYear: number,
|
|
bMonth: number,
|
|
) => {
|
|
const fetchId = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
dispatch({ type: "SET_ERROR", payload: null });
|
|
|
|
try {
|
|
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
|
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
|
|
getDashboardSummary(dateFrom, dateTo),
|
|
getExpensesByCategory(dateFrom, dateTo),
|
|
getCategoryOverTime(dateFrom, dateTo),
|
|
getBudgetVsActualData(bYear, bMonth),
|
|
]);
|
|
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
|
|
} catch (e) {
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({
|
|
type: "SET_ERROR",
|
|
payload: e instanceof Error ? e.message : String(e),
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData(state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth);
|
|
}, [state.period, state.customDateFrom, state.customDateTo, state.budgetYear, state.budgetMonth, fetchData]);
|
|
|
|
const setPeriod = useCallback((period: DashboardPeriod) => {
|
|
dispatch({ type: "SET_PERIOD", payload: period });
|
|
}, []);
|
|
|
|
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
|
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
|
}, []);
|
|
|
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
|
}, []);
|
|
|
|
return { state, setPeriod, setCustomDates, setBudgetMonth };
|
|
}
|