// useDashboard — scoped useReducer hook backing DashboardPage (the `/` home page). // // Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI // row, top movers and budget adherence are the exact Cartes widgets, sourced // from `getCartesSnapshot` against a (year, month) reference period. The // Dashboard keeps its OWN two temporal axes, neither of which is // `useReportsPeriod` (M1/I4 deliberately left the Dashboard untouched): // - `period` / `customDateFrom` / `customDateTo` — a flexible date range, // feeding the two range-based transactional widgets (top-expenses bar // chart, category-over-time trend). // - `referenceYear` / `referenceMonth` — a single reference month, feeding // the Cartes snapshot (KPIs + deltas, top movers, budget adherence, the // income-vs-expenses overlay chart). Defaults to the previous complete // month, like Cartes/Compare (`defaultReferencePeriod`). // // The account (import-source) filter (`accountIds`) is Dashboard-local state // (not routed through `useReportsPeriod`), but applies to every transactional // widget above — both temporal axes. It does NOT scope the net-worth tile // (see below): `balance_accounts` is a disjoint concept from `import_sources`. // // The net-worth tile is fetched independently, once, on mount — it is not a // transactional figure, has no relationship to `period`/`referenceMonth`, and // must not be scoped by `accountIds`. Keeping it in its own effect makes that // decoupling explicit in the code, not just in the UI copy. import { useReducer, useCallback, useEffect, useRef } from "react"; import type { DashboardPeriod, CategoryBreakdownItem, CategoryOverTimeData, CartesSnapshot, ImportSource, } from "../shared/types"; import { getExpensesByCategory, deriveNetWorthTile, type NetWorthTileData, } from "../services/dashboardService"; import { getCategoryOverTime, getCartesSnapshot } from "../services/reportService"; import { getAllImportSources } from "../services/transactionService"; import { getSnapshotTotalsByDate, listSnapshots, listBalanceAccounts, } from "../services/balance.service"; import { computeDateRange } from "../utils/dateRange"; import { defaultReferencePeriod } from "../utils/referencePeriod"; interface DashboardState { categoryBreakdown: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; cartesSnapshot: CartesSnapshot | null; period: DashboardPeriod; customDateFrom: string; customDateTo: string; referenceYear: number; referenceMonth: number; accountIds: number[]; accounts: ImportSource[]; netWorth: NetWorthTileData; isLoading: boolean; error: string | null; } type DashboardAction = | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_DATA"; payload: { categoryBreakdown: CategoryBreakdownItem[]; categoryOverTime: CategoryOverTimeData; cartesSnapshot: CartesSnapshot; }; } | { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } } | { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } } | { type: "SET_ACCOUNT_IDS"; payload: number[] } | { type: "SET_ACCOUNTS"; payload: ImportSource[] } | { type: "SET_NET_WORTH"; payload: NetWorthTileData }; 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 defaultRef = defaultReferencePeriod(); const EMPTY_CATEGORY_OVER_TIME: CategoryOverTimeData = { categories: [], data: [], colors: {}, categoryIds: {}, types: {}, tree: [], }; const EMPTY_NET_WORTH: NetWorthTileData = { visible: false, total: null, asOfDate: null }; const initialState: DashboardState = { categoryBreakdown: [], categoryOverTime: EMPTY_CATEGORY_OVER_TIME, cartesSnapshot: null, period: "year", customDateFrom: yearStartStr, customDateTo: todayStr, referenceYear: defaultRef.year, referenceMonth: defaultRef.month, accountIds: [], accounts: [], netWorth: EMPTY_NET_WORTH, 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, categoryBreakdown: action.payload.categoryBreakdown, categoryOverTime: action.payload.categoryOverTime, cartesSnapshot: action.payload.cartesSnapshot, isLoading: false, }; case "SET_PERIOD": return { ...state, period: action.payload }; case "SET_CUSTOM_DATES": return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo, }; case "SET_REFERENCE_PERIOD": return { ...state, referenceYear: action.payload.year, referenceMonth: action.payload.month }; case "SET_ACCOUNT_IDS": return { ...state, accountIds: action.payload }; case "SET_ACCOUNTS": return { ...state, accounts: action.payload }; case "SET_NET_WORTH": return { ...state, netWorth: action.payload }; 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, refYear: number, refMonth: number, accountIds: 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 [categoryBreakdown, categoryOverTime, cartesSnapshot] = await Promise.all([ getExpensesByCategory(dateFrom, dateTo, accountIds), // typeFilter "expense" (Issue #279): this chart is titled "expenses // over time" but previously aggregated every category type — a // revenue category could silently show up in an "expenses" trend. getCategoryOverTime(dateFrom, dateTo, undefined, accountIds, "expense"), getCartesSnapshot(refYear, refMonth, "month", accountIds), ]); if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_DATA", payload: { categoryBreakdown, categoryOverTime, cartesSnapshot } }); } 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.referenceYear, state.referenceMonth, state.accountIds, ); }, [ state.period, state.customDateFrom, state.customDateTo, state.referenceYear, state.referenceMonth, state.accountIds, fetchData, ]); // One-time: import sources for the FilterPanel's account checkboxes (same // query as Trends/Compare/Budget — see useTrends.ts). useEffect(() => { (async () => { try { const accounts = await getAllImportSources(); dispatch({ type: "SET_ACCOUNTS", payload: accounts }); } catch (e) { dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); } })(); }, []); // One-time: net-worth tile (Bilan). Independent of `period`/`referenceMonth` // and NOT scoped by `accountIds` — a different "account" concept // (balance_accounts vs import_sources) and a different metric (net worth vs // transaction P&L). A failure here only hides the tile (best-effort); it // never surfaces as a page-wide error for what is a secondary widget. useEffect(() => { (async () => { try { const [accounts, snapshots, totals] = await Promise.all([ listBalanceAccounts(), listSnapshots(), getSnapshotTotalsByDate(), ]); dispatch({ type: "SET_NET_WORTH", payload: deriveNetWorthTile(accounts.length, snapshots.length > 0, totals), }); } catch { dispatch({ type: "SET_NET_WORTH", payload: EMPTY_NET_WORTH }); } })(); }, []); 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 setReferencePeriod = useCallback((year: number, month: number) => { dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } }); }, []); const setAccountIds = useCallback((accountIds: number[]) => { dispatch({ type: "SET_ACCOUNT_IDS", payload: accountIds }); }, []); return { state, setPeriod, setCustomDates, setReferencePeriod, setAccountIds }; }