- Services: getCompareMonthOverMonth(year, month) and getCompareYearOverYear(year) return CategoryDelta[] (expense-side, ABS aggregates, parameterised SQL only) - Shared CategoryDelta type with HighlightMover now aliased to it - Flesh out useCompare hook: reducer + fetch + automatic year/month inference from the shared useReportsPeriod `to` date; budget mode skips fetch and delegates to CompareBudgetView which wraps the existing BudgetVsActualTable - Components: CompareModeTabs (MoM/YoY/Budget tabs), ComparePeriodTable (sortable table with signed delta coloring), ComparePeriodChart (diverging horizontal bar chart with ChartPatternDefs for SVG patterns), CompareBudgetView (fetches budget rows for the current target year/month) - ReportsComparePage wires everything with PeriodSelector + ViewModeToggle (storage key reports-viewmode-compare); chart/table toggle is hidden in budget mode since the budget table has its own presentation - i18n keys: reports.compare.modeMoM / modeYoY / modeBudget in FR + EN - 4 new vitest cases for the compare services: parameterised boundaries, January wrap-around to December previous year, delta conversion with previous=0 fallback to null pct, year-over-year spans Fixes #73 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
96 lines
3.1 KiB
TypeScript
96 lines
3.1 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type { CategoryDelta } from "../shared/types";
|
|
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
|
import { useReportsPeriod } from "./useReportsPeriod";
|
|
|
|
export type CompareMode = "mom" | "yoy" | "budget";
|
|
|
|
interface State {
|
|
mode: CompareMode;
|
|
year: number;
|
|
month: number;
|
|
rows: CategoryDelta[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type Action =
|
|
| { type: "SET_MODE"; payload: CompareMode }
|
|
| { type: "SET_PERIOD"; payload: { year: number; month: number } }
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_ROWS"; payload: CategoryDelta[] }
|
|
| { type: "SET_ERROR"; payload: string };
|
|
|
|
const today = new Date();
|
|
const initialState: State = {
|
|
mode: "mom",
|
|
year: today.getFullYear(),
|
|
month: today.getMonth() + 1,
|
|
rows: [],
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "SET_MODE":
|
|
return { ...state, mode: action.payload };
|
|
case "SET_PERIOD":
|
|
return { ...state, year: action.payload.year, month: action.payload.month };
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_ROWS":
|
|
return { ...state, rows: action.payload, isLoading: false, error: null };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useCompare() {
|
|
const { from, to } = useReportsPeriod();
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetch = useCallback(async (mode: CompareMode, year: number, month: number) => {
|
|
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
|
|
const id = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
try {
|
|
const rows =
|
|
mode === "mom"
|
|
? await getCompareMonthOverMonth(year, month)
|
|
: await getCompareYearOverYear(year);
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ROWS", payload: rows });
|
|
} catch (e) {
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetch(state.mode, state.year, state.month);
|
|
}, [fetch, state.mode, state.year, state.month]);
|
|
|
|
// When the URL period changes, use the `to` date to infer the target year/month.
|
|
useEffect(() => {
|
|
const [y, m] = to.split("-").map(Number);
|
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
|
if (y !== state.year || m !== state.month) {
|
|
dispatch({ type: "SET_PERIOD", payload: { year: y, month: m } });
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [to]);
|
|
|
|
const setMode = useCallback((m: CompareMode) => {
|
|
dispatch({ type: "SET_MODE", payload: m });
|
|
}, []);
|
|
|
|
const setTargetPeriod = useCallback((year: number, month: number) => {
|
|
dispatch({ type: "SET_PERIOD", payload: { year, month } });
|
|
}, []);
|
|
|
|
return { ...state, setMode, setTargetPeriod, from, to };
|
|
}
|