Simpl-Resultat/src/hooks/useDashboard.ts
le king fu 193ef1d9dd
All checks were successful
PR Check / rust (pull_request) Successful in 20m42s
PR Check / frontend (pull_request) Successful in 2m22s
feat(dashboard): converge home page on the Cartes report model
DashboardPage now reuses the Cartes report's presentational widgets
(KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from
getCartesSnapshot against a Dashboard-owned reference month (defaults to
the last complete month). The expense-only pie chart is replaced by a
ranked bar chart of top expense categories (resurrecting the previously
unused CategoryBarChart), and the account (import-source) filter
introduced on Trends/Compare/Budget now also applies to the Dashboard's
own transactional widgets via a Dashboard-local accountIds state (kept
separate from useReportsPeriod, since the Dashboard owns its own two
temporal axes).

A new net-worth tile surfaces the Balance sheet's latest total
(getSnapshotTotalsByDate) — a distinct metric from every transactional
card here, so it stays hidden (never a misleading "$0") until at least
one balance account has a recorded snapshot, reusing deriveLandingState
rather than re-inferring emptiness from nulls. It is not scoped by the
account filter (balance_accounts is a disjoint concept from
import_sources) and is fetched independently on mount.

The category-over-time trend chart now passes typeFilter "expense",
fixing a latent mismatch where a revenue category could silently show
up in a chart titled "expenses over time".

Resolves #279
2026-07-11 17:13:23 -04:00

261 lines
9.1 KiB
TypeScript

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