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
261 lines
9.1 KiB
TypeScript
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 };
|
|
}
|