refactor: split useReports into per-domain hooks + URL-bookmarked period (#70)
- New useReportsPeriod hook reads/writes period via ?from=&to=&period= URL params, default civil year, pure resolver exported for tests - New per-domain hooks: useHighlights, useTrends, useCompare, useCategoryZoom (stubs wired to useReportsPeriod, to be fleshed out in #71-#74) - Rewire legacy useReports to consume useReportsPeriod; keep backward-compat state shape (period/customDateFrom/customDateTo) so /reports tabs keep working - Mark useReports @deprecated pending removal in #76 - Tests: 7 new cases covering resolveReportsPeriod defaults, bookmarks, invalid inputs, preset resolution Fixes #70 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a50be5caf6
commit
6a6a196467
7 changed files with 492 additions and 76 deletions
53
src/hooks/useCategoryZoom.ts
Normal file
53
src/hooks/useCategoryZoom.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useReducer, useCallback } from "react";
|
||||||
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
zoomedCategoryId: number | null;
|
||||||
|
rollupChildren: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_CATEGORY"; payload: number | null }
|
||||||
|
| { type: "TOGGLE_ROLLUP"; payload: boolean }
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
zoomedCategoryId: null,
|
||||||
|
rollupChildren: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_CATEGORY":
|
||||||
|
return { ...state, zoomedCategoryId: action.payload };
|
||||||
|
case "TOGGLE_ROLLUP":
|
||||||
|
return { ...state, rollupChildren: action.payload };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategoryZoom() {
|
||||||
|
const { from, to } = useReportsPeriod();
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
|
const setCategory = useCallback((id: number | null) => {
|
||||||
|
dispatch({ type: "SET_CATEGORY", payload: id });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setRollupChildren = useCallback((flag: boolean) => {
|
||||||
|
dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Real fetch lives in Issue #74 (getCategoryZoom with recursive CTE).
|
||||||
|
return { ...state, setCategory, setRollupChildren, from, to };
|
||||||
|
}
|
||||||
46
src/hooks/useCompare.ts
Normal file
46
src/hooks/useCompare.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useReducer, useCallback } from "react";
|
||||||
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
export type CompareMode = "mom" | "yoy" | "budget";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
mode: CompareMode;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_MODE"; payload: CompareMode }
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
mode: "mom",
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_MODE":
|
||||||
|
return { ...state, mode: action.payload };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
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 setMode = useCallback((m: CompareMode) => {
|
||||||
|
dispatch({ type: "SET_MODE", payload: m });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Issue #73 will fetch via reportService.getCompareMonthOverMonth / ...YearOverYear
|
||||||
|
return { ...state, setMode, from, to };
|
||||||
|
}
|
||||||
64
src/hooks/useHighlights.ts
Normal file
64
src/hooks/useHighlights.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
// Stub highlights shape — to be fleshed out in Issue #71.
|
||||||
|
export interface HighlightsData {
|
||||||
|
netBalanceCurrent: number;
|
||||||
|
netBalanceYtd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
data: HighlightsData | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_DATA"; payload: HighlightsData }
|
||||||
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_DATA":
|
||||||
|
return { ...state, data: action.payload, isLoading: false, error: null };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHighlights() {
|
||||||
|
const { from, to } = useReportsPeriod();
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const id = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
try {
|
||||||
|
// Real implementation in Issue #71 will call reportService.getHighlights
|
||||||
|
const stub: HighlightsData = { netBalanceCurrent: 0, netBalanceYtd: 0 };
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_DATA", payload: stub });
|
||||||
|
} catch (e) {
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch();
|
||||||
|
}, [fetch, from, to]);
|
||||||
|
|
||||||
|
return { ...state, from, to };
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
/**
|
||||||
|
* @deprecated — legacy monolithic reports hook. Kept during the refonte
|
||||||
|
* (Issues #70 → #76) so the pre-existing 4 tabs on `/reports` keep working
|
||||||
|
* while the new per-domain hooks (useHighlights / useTrends / useCompare /
|
||||||
|
* useCategoryZoom) are wired up. Will be removed in Issue #76 once every
|
||||||
|
* report migrates to its own route.
|
||||||
|
*/
|
||||||
import { useReducer, useCallback, useEffect, useRef } from "react";
|
import { useReducer, useCallback, useEffect, useRef } from "react";
|
||||||
import type {
|
import type {
|
||||||
ReportTab,
|
ReportTab,
|
||||||
DashboardPeriod,
|
|
||||||
MonthlyTrendItem,
|
MonthlyTrendItem,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
CategoryOverTimeData,
|
CategoryOverTimeData,
|
||||||
|
|
@ -10,15 +16,12 @@ import type {
|
||||||
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
import { getExpensesByCategory } from "../services/dashboardService";
|
import { getExpensesByCategory } from "../services/dashboardService";
|
||||||
import { getBudgetVsActualData } from "../services/budgetService";
|
import { getBudgetVsActualData } from "../services/budgetService";
|
||||||
import { computeDateRange } from "../utils/dateRange";
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
export type CategoryTypeFilter = "expense" | "income" | "transfer" | null;
|
||||||
|
|
||||||
interface ReportsState {
|
interface ReportsState {
|
||||||
tab: ReportTab;
|
tab: ReportTab;
|
||||||
period: DashboardPeriod;
|
|
||||||
customDateFrom: string;
|
|
||||||
customDateTo: string;
|
|
||||||
sourceId: number | null;
|
sourceId: number | null;
|
||||||
categoryType: CategoryTypeFilter;
|
categoryType: CategoryTypeFilter;
|
||||||
monthlyTrends: MonthlyTrendItem[];
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
|
|
@ -33,7 +36,6 @@ interface ReportsState {
|
||||||
|
|
||||||
type ReportsAction =
|
type ReportsAction =
|
||||||
| { type: "SET_TAB"; payload: ReportTab }
|
| { type: "SET_TAB"; payload: ReportTab }
|
||||||
| { type: "SET_PERIOD"; payload: DashboardPeriod }
|
|
||||||
| { type: "SET_LOADING"; payload: boolean }
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
| { type: "SET_ERROR"; payload: string | null }
|
| { type: "SET_ERROR"; payload: string | null }
|
||||||
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
| { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
|
|
@ -41,19 +43,13 @@ type ReportsAction =
|
||||||
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
| { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } }
|
||||||
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
| { type: "SET_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
|
||||||
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } }
|
|
||||||
| { type: "SET_SOURCE_ID"; payload: number | null }
|
| { type: "SET_SOURCE_ID"; payload: number | null }
|
||||||
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
|
| { type: "SET_CATEGORY_TYPE"; payload: CategoryTypeFilter };
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
||||||
const monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
|
||||||
|
|
||||||
const initialState: ReportsState = {
|
const initialState: ReportsState = {
|
||||||
tab: "trends",
|
tab: "trends",
|
||||||
period: "6months",
|
|
||||||
customDateFrom: monthStartStr,
|
|
||||||
customDateTo: todayStr,
|
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
categoryType: "expense",
|
categoryType: "expense",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
|
|
@ -70,8 +66,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "SET_TAB":
|
case "SET_TAB":
|
||||||
return { ...state, tab: action.payload };
|
return { ...state, tab: action.payload };
|
||||||
case "SET_PERIOD":
|
|
||||||
return { ...state, period: action.payload };
|
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.payload };
|
return { ...state, isLoading: action.payload };
|
||||||
case "SET_ERROR":
|
case "SET_ERROR":
|
||||||
|
|
@ -86,8 +80,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month };
|
||||||
case "SET_BUDGET_VS_ACTUAL":
|
case "SET_BUDGET_VS_ACTUAL":
|
||||||
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
return { ...state, budgetVsActual: action.payload, isLoading: false };
|
||||||
case "SET_CUSTOM_DATES":
|
|
||||||
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
|
|
||||||
case "SET_SOURCE_ID":
|
case "SET_SOURCE_ID":
|
||||||
return { ...state, sourceId: action.payload };
|
return { ...state, sourceId: action.payload };
|
||||||
case "SET_CATEGORY_TYPE":
|
case "SET_CATEGORY_TYPE":
|
||||||
|
|
@ -97,83 +89,84 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated — see module-level comment. */
|
||||||
export function useReports() {
|
export function useReports() {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const { from, to, period, setPeriod, setCustomDates } = useReportsPeriod();
|
||||||
|
const [innerState, dispatch] = useReducer(reducer, initialState);
|
||||||
const fetchIdRef = useRef(0);
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
const fetchData = useCallback(async (
|
const fetchData = useCallback(
|
||||||
tab: ReportTab,
|
async (
|
||||||
period: DashboardPeriod,
|
tab: ReportTab,
|
||||||
budgetYear: number,
|
dateFrom: string,
|
||||||
budgetMonth: number,
|
dateTo: string,
|
||||||
customFrom?: string,
|
budgetYear: number,
|
||||||
customTo?: string,
|
budgetMonth: number,
|
||||||
srcId?: number | null,
|
srcId: number | null,
|
||||||
catType?: CategoryTypeFilter,
|
catType: CategoryTypeFilter,
|
||||||
) => {
|
) => {
|
||||||
const fetchId = ++fetchIdRef.current;
|
const fetchId = ++fetchIdRef.current;
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "trends": {
|
case "trends": {
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
||||||
const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined);
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
||||||
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
|
break;
|
||||||
break;
|
}
|
||||||
}
|
case "byCategory": {
|
||||||
case "byCategory": {
|
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined);
|
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
break;
|
||||||
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
|
}
|
||||||
break;
|
case "overTime": {
|
||||||
}
|
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
|
||||||
case "overTime": {
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined);
|
break;
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
}
|
||||||
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
case "budgetVsActual": {
|
||||||
break;
|
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
|
||||||
}
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
case "budgetVsActual": {
|
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
||||||
const data = await getBudgetVsActualData(budgetYear, budgetMonth);
|
break;
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
}
|
||||||
dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data });
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (fetchId !== fetchIdRef.current) return;
|
||||||
|
dispatch({
|
||||||
|
type: "SET_ERROR",
|
||||||
|
payload: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
if (fetchId !== fetchIdRef.current) return;
|
[],
|
||||||
dispatch({
|
);
|
||||||
type: "SET_ERROR",
|
|
||||||
payload: e instanceof Error ? e.message : String(e),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType);
|
fetchData(
|
||||||
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType, fetchData]);
|
innerState.tab,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
innerState.budgetYear,
|
||||||
|
innerState.budgetMonth,
|
||||||
|
innerState.sourceId,
|
||||||
|
innerState.categoryType,
|
||||||
|
);
|
||||||
|
}, [fetchData, innerState.tab, from, to, innerState.budgetYear, innerState.budgetMonth, innerState.sourceId, innerState.categoryType]);
|
||||||
|
|
||||||
const setTab = useCallback((tab: ReportTab) => {
|
const setTab = useCallback((tab: ReportTab) => {
|
||||||
dispatch({ type: "SET_TAB", payload: tab });
|
dispatch({ type: "SET_TAB", payload: tab });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setPeriod = useCallback((period: DashboardPeriod) => {
|
|
||||||
dispatch({ type: "SET_PERIOD", payload: period });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setBudgetMonth = useCallback((year: number, month: number) => {
|
const setBudgetMonth = useCallback((year: number, month: number) => {
|
||||||
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
dispatch({ type: "SET_BUDGET_MONTH", payload: { year, month } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
|
|
||||||
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setSourceId = useCallback((id: number | null) => {
|
const setSourceId = useCallback((id: number | null) => {
|
||||||
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
dispatch({ type: "SET_SOURCE_ID", payload: id });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -182,5 +175,12 @@ export function useReports() {
|
||||||
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
dispatch({ type: "SET_CATEGORY_TYPE", payload: catType });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
...innerState,
|
||||||
|
period,
|
||||||
|
customDateFrom: from,
|
||||||
|
customDateTo: to,
|
||||||
|
};
|
||||||
|
|
||||||
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType };
|
return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/hooks/useReportsPeriod.test.ts
Normal file
53
src/hooks/useReportsPeriod.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
describe("resolveReportsPeriod", () => {
|
||||||
|
const fixedToday = new Date("2026-04-14T12:00:00Z");
|
||||||
|
|
||||||
|
it("defaults to current civil year when no URL params are set", () => {
|
||||||
|
const result = resolveReportsPeriod(null, null, null, fixedToday);
|
||||||
|
expect(result.from).toBe("2026-01-01");
|
||||||
|
expect(result.to).toBe("2026-12-31");
|
||||||
|
expect(result.period).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores state from bookmarked from/to params", () => {
|
||||||
|
const result = resolveReportsPeriod("2025-03-01", "2025-06-30", null, fixedToday);
|
||||||
|
expect(result.from).toBe("2025-03-01");
|
||||||
|
expect(result.to).toBe("2025-06-30");
|
||||||
|
expect(result.period).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps period=yearly alongside explicit from/to", () => {
|
||||||
|
const result = resolveReportsPeriod("2024-01-01", "2024-12-31", "year", fixedToday);
|
||||||
|
expect(result.period).toBe("year");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed dates and falls back to the civil year", () => {
|
||||||
|
const result = resolveReportsPeriod("not-a-date", "also-not", null, fixedToday);
|
||||||
|
expect(result.from).toBe("2026-01-01");
|
||||||
|
expect(result.to).toBe("2026-12-31");
|
||||||
|
expect(result.period).toBe("custom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves preset period values without from/to", () => {
|
||||||
|
const result = resolveReportsPeriod(null, null, "6months", fixedToday);
|
||||||
|
expect(result.period).toBe("6months");
|
||||||
|
expect(result.from).toBeTruthy();
|
||||||
|
expect(result.to).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid period string and falls back to civil year custom", () => {
|
||||||
|
const result = resolveReportsPeriod(null, null, "bogus", fixedToday);
|
||||||
|
expect(result.period).toBe("custom");
|
||||||
|
expect(result.from).toBe("2026-01-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats `all` as a preset with empty range (service handles the clauses)", () => {
|
||||||
|
const result = resolveReportsPeriod(null, null, "all", fixedToday);
|
||||||
|
expect(result.period).toBe("all");
|
||||||
|
// Fallback civil year when computeDateRange returns empty
|
||||||
|
expect(result.from).toBe("2026-01-01");
|
||||||
|
expect(result.to).toBe("2026-12-31");
|
||||||
|
});
|
||||||
|
});
|
||||||
119
src/hooks/useReportsPeriod.ts
Normal file
119
src/hooks/useReportsPeriod.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import type { DashboardPeriod } from "../shared/types";
|
||||||
|
import { computeDateRange } from "../utils/dateRange";
|
||||||
|
|
||||||
|
const VALID_PERIODS: readonly DashboardPeriod[] = [
|
||||||
|
"month",
|
||||||
|
"3months",
|
||||||
|
"6months",
|
||||||
|
"year",
|
||||||
|
"12months",
|
||||||
|
"all",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isValidPeriod(p: string | null): p is DashboardPeriod {
|
||||||
|
return p !== null && (VALID_PERIODS as readonly string[]).includes(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidIsoDate(s: string | null): s is string {
|
||||||
|
return !!s && /^\d{4}-\d{2}-\d{2}$/.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYearRange(today: Date = new Date()): { from: string; to: string } {
|
||||||
|
const year = today.getFullYear();
|
||||||
|
return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure resolver used by the hook and unit tests. Exposed to keep the core
|
||||||
|
* logic hookless and testable without rendering a router.
|
||||||
|
*/
|
||||||
|
export function resolveReportsPeriod(
|
||||||
|
rawFrom: string | null,
|
||||||
|
rawTo: string | null,
|
||||||
|
rawPeriod: string | null,
|
||||||
|
today: Date = new Date(),
|
||||||
|
): { from: string; to: string; period: DashboardPeriod } {
|
||||||
|
if (isValidIsoDate(rawFrom) && isValidIsoDate(rawTo)) {
|
||||||
|
const p = isValidPeriod(rawPeriod) ? rawPeriod : "custom";
|
||||||
|
return { from: rawFrom, to: rawTo, period: p };
|
||||||
|
}
|
||||||
|
if (isValidPeriod(rawPeriod) && rawPeriod !== "custom") {
|
||||||
|
const range = computeDateRange(rawPeriod);
|
||||||
|
const { from: defaultFrom, to: defaultTo } = currentYearRange(today);
|
||||||
|
return {
|
||||||
|
from: range.dateFrom ?? defaultFrom,
|
||||||
|
to: range.dateTo ?? defaultTo,
|
||||||
|
period: rawPeriod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { from, to } = currentYearRange(today);
|
||||||
|
return { from, to, period: "custom" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseReportsPeriodResult {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
period: DashboardPeriod;
|
||||||
|
setPeriod: (period: DashboardPeriod) => void;
|
||||||
|
setCustomDates: (from: string, to: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads/writes the active reporting period via the URL query string so it is
|
||||||
|
* bookmarkable and shared across the four report sub-routes.
|
||||||
|
*
|
||||||
|
* Defaults to the current civil year (Jan 1 → Dec 31).
|
||||||
|
*/
|
||||||
|
export function useReportsPeriod(): UseReportsPeriodResult {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const rawPeriod = searchParams.get("period");
|
||||||
|
const rawFrom = searchParams.get("from");
|
||||||
|
const rawTo = searchParams.get("to");
|
||||||
|
|
||||||
|
const { from, to, period } = useMemo(
|
||||||
|
() => resolveReportsPeriod(rawFrom, rawTo, rawPeriod),
|
||||||
|
[rawPeriod, rawFrom, rawTo],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPeriod = useCallback(
|
||||||
|
(next: DashboardPeriod) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const params = new URLSearchParams(prev);
|
||||||
|
if (next === "custom") {
|
||||||
|
params.set("period", "custom");
|
||||||
|
} else {
|
||||||
|
params.set("period", next);
|
||||||
|
params.delete("from");
|
||||||
|
params.delete("to");
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCustomDates = useCallback(
|
||||||
|
(nextFrom: string, nextTo: string) => {
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const params = new URLSearchParams(prev);
|
||||||
|
params.set("period", "custom");
|
||||||
|
params.set("from", nextFrom);
|
||||||
|
params.set("to", nextTo);
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { from, to, period, setPeriod, setCustomDates };
|
||||||
|
}
|
||||||
81
src/hooks/useTrends.ts
Normal file
81
src/hooks/useTrends.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useReducer, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { MonthlyTrendItem, CategoryOverTimeData } from "../shared/types";
|
||||||
|
import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService";
|
||||||
|
import { useReportsPeriod } from "./useReportsPeriod";
|
||||||
|
|
||||||
|
export type TrendsSubView = "global" | "byCategory";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
subView: TrendsSubView;
|
||||||
|
monthlyTrends: MonthlyTrendItem[];
|
||||||
|
categoryOverTime: CategoryOverTimeData;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_SUBVIEW"; payload: TrendsSubView }
|
||||||
|
| { type: "SET_LOADING"; payload: boolean }
|
||||||
|
| { type: "SET_TRENDS"; payload: MonthlyTrendItem[] }
|
||||||
|
| { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData }
|
||||||
|
| { type: "SET_ERROR"; payload: string };
|
||||||
|
|
||||||
|
const initialState: State = {
|
||||||
|
subView: "global",
|
||||||
|
monthlyTrends: [],
|
||||||
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_SUBVIEW":
|
||||||
|
return { ...state, subView: action.payload };
|
||||||
|
case "SET_LOADING":
|
||||||
|
return { ...state, isLoading: action.payload };
|
||||||
|
case "SET_TRENDS":
|
||||||
|
return { ...state, monthlyTrends: action.payload, isLoading: false, error: null };
|
||||||
|
case "SET_CATEGORY_OVER_TIME":
|
||||||
|
return { ...state, categoryOverTime: action.payload, isLoading: false, error: null };
|
||||||
|
case "SET_ERROR":
|
||||||
|
return { ...state, error: action.payload, isLoading: false };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrends() {
|
||||||
|
const { from, to } = useReportsPeriod();
|
||||||
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
const fetchIdRef = useRef(0);
|
||||||
|
|
||||||
|
const fetch = useCallback(async (subView: TrendsSubView, dateFrom: string, dateTo: string) => {
|
||||||
|
const id = ++fetchIdRef.current;
|
||||||
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
|
try {
|
||||||
|
if (subView === "global") {
|
||||||
|
const data = await getMonthlyTrends(dateFrom, dateTo);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_TRENDS", payload: data });
|
||||||
|
} else {
|
||||||
|
const data = await getCategoryOverTime(dateFrom, dateTo);
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (id !== fetchIdRef.current) return;
|
||||||
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(state.subView, from, to);
|
||||||
|
}, [fetch, state.subView, from, to]);
|
||||||
|
|
||||||
|
const setSubView = useCallback((sv: TrendsSubView) => {
|
||||||
|
dispatch({ type: "SET_SUBVIEW", payload: sv });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...state, setSubView, from, to };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue