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>
204 lines
7.3 KiB
TypeScript
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 };
|
|
}
|