- Transform /reports into a hub: highlights panel + 4 nav cards - New service: reportService.getHighlights (parameterised SQL, deterministic via referenceDate argument for tests, computes current-month balance, YTD, 12-month sparkline series, top expense movers vs previous month, top recent transactions within configurable 30/60/90 day window) - Extended types: HighlightsData, HighlightMover, MonthBalance - Wired useHighlights hook with reducer + window-days state - Hub tiles (flat naming under src/components/reports): HubNetBalanceTile, HubTopMoversTile, HubTopTransactionsTile, HubHighlightsPanel, HubReportNavCard - Detailed ReportsHighlightsPage: balance tiles, sortable top movers table, diverging bar chart (Recharts + patterns SVG), top transactions list with 30/60/90 window toggle; ViewModeToggle persistence keyed as reports-viewmode-highlights - New i18n keys: reports.hub.*, reports.highlights.* - 5 new vitest cases: empty profile, parameterised queries, window sizing, delta computation, zero-previous divisor handling Fixes #71 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
68 lines
2.1 KiB
TypeScript
68 lines
2.1 KiB
TypeScript
import { useReducer, useEffect, useRef, useCallback } from "react";
|
|
import type { HighlightsData } from "../shared/types";
|
|
import { getHighlights } from "../services/reportService";
|
|
import { useReportsPeriod } from "./useReportsPeriod";
|
|
|
|
interface State {
|
|
data: HighlightsData | null;
|
|
windowDays: 30 | 60 | 90;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type Action =
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_DATA"; payload: HighlightsData }
|
|
| { type: "SET_ERROR"; payload: string }
|
|
| { type: "SET_WINDOW_DAYS"; payload: 30 | 60 | 90 };
|
|
|
|
const initialState: State = {
|
|
data: null,
|
|
windowDays: 30,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_DATA":
|
|
return { ...state, data: action.payload, isLoading: false, error: null };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
case "SET_WINDOW_DAYS":
|
|
return { ...state, windowDays: action.payload };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useHighlights() {
|
|
const { from, to } = useReportsPeriod();
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetch = useCallback(async (windowDays: 30 | 60 | 90, referenceDate: string) => {
|
|
const id = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
try {
|
|
const data = await getHighlights(windowDays, referenceDate);
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_DATA", payload: data });
|
|
} catch (e) {
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetch(state.windowDays, to);
|
|
}, [fetch, state.windowDays, to]);
|
|
|
|
const setWindowDays = useCallback((d: 30 | 60 | 90) => {
|
|
dispatch({ type: "SET_WINDOW_DAYS", payload: d });
|
|
}, []);
|
|
|
|
return { ...state, setWindowDays, from, to };
|
|
}
|