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