Simpl-Resultat/src/hooks/useHighlights.ts
le king fu ac9c8afc4a
All checks were successful
PR Check / rust (pull_request) Successful in 24m54s
PR Check / frontend (pull_request) Successful in 2m32s
PR Check / rust (push) Successful in 24m14s
PR Check / frontend (push) Successful in 2m26s
feat: reports hub with highlights panel and detailed highlights page (#71)
- 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>
2026-04-14 14:47:55 -04:00

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 };
}