- Extract shared defaultReferencePeriod helper (src/utils/referencePeriod.ts) - useHighlights now reads ?refY=YYYY&refM=MM, defaults to previous month - getHighlights signature: (referenceYear, referenceMonth, ytdYear, windowDays, ...) - YTD tile pinned to Jan 1 of current civil year, independent of reference month - CompareReferenceMonthPicker surfaced on /reports/highlights - Hub highlights panel inherits the same default via useHighlights - useCartes and useCompare now delegate their default-period helpers to the shared util
147 lines
5 KiB
TypeScript
147 lines
5 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type { CategoryDelta } from "../shared/types";
|
|
import { getCompareMonthOverMonth, getCompareYearOverYear } from "../services/reportService";
|
|
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[];
|
|
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_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 };
|
|
}
|
|
|
|
const defaultRef = defaultReferencePeriod();
|
|
const initialState: State = {
|
|
mode: "actual",
|
|
subMode: "mom",
|
|
year: defaultRef.year,
|
|
month: defaultRef.month,
|
|
rows: [],
|
|
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_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useCompare() {
|
|
const { from, to } = useReportsPeriod();
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetch = useCallback(
|
|
async (mode: CompareMode, subMode: CompareSubMode, year: number, month: 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)
|
|
: await getCompareYearOverYear(year, month);
|
|
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);
|
|
}, [fetch, state.mode, state.subMode, state.year, state.month]);
|
|
|
|
// When the URL period changes, align the reference month with `to`.
|
|
// The explicit dropdown remains the primary selector — this effect only
|
|
// keeps the two in sync when the user navigates via PeriodSelector.
|
|
useEffect(() => {
|
|
const [y, m] = to.split("-").map(Number);
|
|
if (!Number.isFinite(y) || !Number.isFinite(m)) return;
|
|
if (y !== state.year || m !== state.month) {
|
|
dispatch({ type: "SET_REFERENCE_PERIOD", payload: { year: y, month: m } });
|
|
}
|
|
// 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 };
|
|
}
|