Simpl-Resultat/src/hooks/useCompare.ts
le king fu fe9ae0118c feat(reports): adopt shared FilterPanel on Compare and Budget
Mounts <FilterPanel> on ReportsComparePage (next to the existing
CompareReferenceMonthPicker) and BudgetPage (next to the existing
YearNavigator), keeping each page's temporal control unchanged. Threads
accountIds from useReportsPeriod through useCompare into
getCompareMonthOverMonth/getCompareYearOverYear, and through
CompareBudgetView into getBudgetVsActualData — closing the gap where
that sub-tab silently ignored the filter while the rest of the Compare
page respected it.

Budget: the issue named getBudgetVsActualData as the target for
useBudget, but that hook never calls it (it's exclusive to
CompareBudgetView/Dashboard/Cartes) — its only real actuals fetch is
getActualTotalsForYear(year - 1), the previous-year reference column.
Extended that function with the same optional accountIds pass-through
established by #273, rather than wiring in an unrelated call shape.

Both hooks fetch their account checkbox list via getAllImportSources,
mirroring the useTrends/#275 pattern. Empty selection = no filter,
byte-identical to pre-#276 output (regression-tested).

Resolves #276

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:52:21 -04:00

204 lines
7.3 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type { CategoryDelta, ImportSource } from "../shared/types";
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
import { getAllImportSources } from "../services/transactionService";
import { useReportsPeriod } from "./useReportsPeriod";
import { defaultReferencePeriod as sharedDefaultReferencePeriod } from "../utils/referencePeriod";
export type CompareMode = "actual" | "budget";
export type CompareSubMode = "mom" | "yoy";
interface State {
mode: CompareMode;
subMode: CompareSubMode;
year: number;
month: number;
rows: CategoryDelta[];
accounts: ImportSource[];
isLoading: boolean;
error: string | null;
}
type Action =
| { type: "SET_MODE"; payload: CompareMode }
| { type: "SET_SUB_MODE"; payload: CompareSubMode }
| { type: "SET_REFERENCE_PERIOD"; payload: { year: number; month: number } }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ROWS"; payload: CategoryDelta[] }
| { type: "SET_ACCOUNTS"; payload: ImportSource[] }
| { type: "SET_ERROR"; payload: string };
/**
* Wrap-around helper: returns (year, month) shifted back by one month.
* Example: previousMonth(2026, 1) -> { year: 2025, month: 12 }.
*/
export function previousMonth(year: number, month: number): { year: number; month: number } {
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
/**
* Default reference period for the Compare report: the month preceding `today`.
* Thin wrapper around the shared helper — kept as a named export so existing
* imports (and tests) keep working.
*/
export function defaultReferencePeriod(today: Date = new Date()): { year: number; month: number } {
return sharedDefaultReferencePeriod(today);
}
/**
* Returns the comparison meta for a given subMode + reference period.
* - MoM: previous month vs current month
* - YoY: same month previous year vs current year
*/
export function comparisonMeta(
subMode: CompareSubMode,
year: number,
month: number,
): { previousYear: number; previousMonth: number } {
if (subMode === "mom") {
const prev = previousMonth(year, month);
return { previousYear: prev.year, previousMonth: prev.month };
}
return { previousYear: year - 1, previousMonth: month };
}
/**
* Pure decision for the URL-period → reference-month sync effect.
*
* Gates on a *change* of `to` (the period's upper bound) rather than a
* fire-once flag: seeded with the initial `to`, the mount is a no-op, so the
* previous-month default from initialState survives instead of snapping to the
* civil-year December that useReportsPeriod yields by default (Issue #253).
* Because it keys off a value change, it is idempotent under React StrictMode's
* dev double-invoke of effects — called twice with the same `to` it dispatches
* nothing the second time (a fire-once boolean would wrongly sync on run #2).
*
* Returns the `to` to remember plus an optional reference period to dispatch.
*/
export function syncReferenceOnPeriodChange(
lastSyncedTo: string,
to: string,
currentYear: number,
currentMonth: number,
): { lastSyncedTo: string; next: { year: number; month: number } | null } {
if (to === lastSyncedTo) return { lastSyncedTo, next: null };
const [y, m] = to.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m)) return { lastSyncedTo: to, next: null };
if (y === currentYear && m === currentMonth) return { lastSyncedTo: to, next: null };
return { lastSyncedTo: to, next: { year: y, month: m } };
}
const defaultRef = defaultReferencePeriod();
const initialState: State = {
mode: "actual",
subMode: "mom",
year: defaultRef.year,
month: defaultRef.month,
rows: [],
accounts: [],
isLoading: false,
error: null,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MODE":
return { ...state, mode: action.payload };
case "SET_SUB_MODE":
return { ...state, subMode: action.payload };
case "SET_REFERENCE_PERIOD":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ROWS":
return { ...state, rows: action.payload, isLoading: false, error: null };
case "SET_ACCOUNTS":
return { ...state, accounts: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
default:
return state;
}
}
export function useCompare() {
const { from, to, accountIds } = useReportsPeriod();
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetch = useCallback(
async (
mode: CompareMode,
subMode: CompareSubMode,
year: number,
month: number,
ids: number[],
) => {
if (mode === "budget") return; // Budget view uses BudgetVsActualTable directly
const id = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
try {
const rows =
subMode === "mom"
? await getCompareMonthOverMonth(year, month, ids)
: await getCompareYearOverYear(year, month, ids);
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ROWS", payload: rows });
} catch (e) {
if (id !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[],
);
useEffect(() => {
fetch(state.mode, state.subMode, state.year, state.month, accountIds);
}, [fetch, state.mode, state.subMode, state.year, state.month, accountIds]);
// Load the import-source list once, for the FilterPanel's account checkboxes
// (same query as the Trends/Transactions source filters — 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) });
}
})();
}, []);
// Keep the reference month in sync with the URL period when the user navigates
// via PeriodSelector — but not on mount. The ref is seeded with the initial
// `to`, so syncReferenceOnPeriodChange only fires on an actual change of `to`,
// leaving the previous-month default from initialState intact (Issue #253) and
// staying idempotent under StrictMode's dev double-invoke.
const lastSyncedToRef = useRef(to);
useEffect(() => {
const { lastSyncedTo, next } = syncReferenceOnPeriodChange(
lastSyncedToRef.current,
to,
state.year,
state.month,
);
lastSyncedToRef.current = lastSyncedTo;
if (next) dispatch({ type: "SET_REFERENCE_PERIOD", payload: next });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to]);
const setMode = useCallback((m: CompareMode) => {
dispatch({ type: "SET_MODE", payload: m });
}, []);
const setSubMode = useCallback((s: CompareSubMode) => {
dispatch({ type: "SET_SUB_MODE", payload: s });
}, []);
const setReferencePeriod = useCallback((year: number, month: number) => {
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year, month } });
}, []);
return { ...state, setMode, setSubMode, setReferencePeriod, from, to };
}