From 6a6a196467505cde26f85b39b2ff95a088042c51 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 14 Apr 2026 14:37:33 -0400 Subject: [PATCH] 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) --- src/hooks/useCategoryZoom.ts | 53 ++++++++++ src/hooks/useCompare.ts | 46 +++++++++ src/hooks/useHighlights.ts | 64 ++++++++++++ src/hooks/useReports.ts | 152 ++++++++++++++--------------- src/hooks/useReportsPeriod.test.ts | 53 ++++++++++ src/hooks/useReportsPeriod.ts | 119 ++++++++++++++++++++++ src/hooks/useTrends.ts | 81 +++++++++++++++ 7 files changed, 492 insertions(+), 76 deletions(-) create mode 100644 src/hooks/useCategoryZoom.ts create mode 100644 src/hooks/useCompare.ts create mode 100644 src/hooks/useHighlights.ts create mode 100644 src/hooks/useReportsPeriod.test.ts create mode 100644 src/hooks/useReportsPeriod.ts create mode 100644 src/hooks/useTrends.ts diff --git a/src/hooks/useCategoryZoom.ts b/src/hooks/useCategoryZoom.ts new file mode 100644 index 0000000..fcc5e00 --- /dev/null +++ b/src/hooks/useCategoryZoom.ts @@ -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 }; +} diff --git a/src/hooks/useCompare.ts b/src/hooks/useCompare.ts new file mode 100644 index 0000000..90f0fd7 --- /dev/null +++ b/src/hooks/useCompare.ts @@ -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 }; +} diff --git a/src/hooks/useHighlights.ts b/src/hooks/useHighlights.ts new file mode 100644 index 0000000..6b10d8c --- /dev/null +++ b/src/hooks/useHighlights.ts @@ -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 }; +} diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 0a2a54f..47d0718 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -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 type { ReportTab, - DashboardPeriod, MonthlyTrendItem, CategoryBreakdownItem, CategoryOverTimeData, @@ -10,15 +16,12 @@ import type { import { getMonthlyTrends, getCategoryOverTime } from "../services/reportService"; import { getExpensesByCategory } from "../services/dashboardService"; import { getBudgetVsActualData } from "../services/budgetService"; -import { computeDateRange } from "../utils/dateRange"; +import { useReportsPeriod } from "./useReportsPeriod"; export type CategoryTypeFilter = "expense" | "income" | "transfer" | null; interface ReportsState { tab: ReportTab; - period: DashboardPeriod; - customDateFrom: string; - customDateTo: string; sourceId: number | null; categoryType: CategoryTypeFilter; monthlyTrends: MonthlyTrendItem[]; @@ -33,7 +36,6 @@ interface ReportsState { type ReportsAction = | { type: "SET_TAB"; payload: ReportTab } - | { type: "SET_PERIOD"; payload: DashboardPeriod } | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_MONTHLY_TRENDS"; payload: MonthlyTrendItem[] } @@ -41,19 +43,13 @@ type ReportsAction = | { type: "SET_CATEGORY_OVER_TIME"; payload: CategoryOverTimeData } | { type: "SET_BUDGET_MONTH"; payload: { year: number; month: number } } | { 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_CATEGORY_TYPE"; payload: CategoryTypeFilter }; 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 = { tab: "trends", - period: "6months", - customDateFrom: monthStartStr, - customDateTo: todayStr, sourceId: null, categoryType: "expense", monthlyTrends: [], @@ -70,8 +66,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState { switch (action.type) { case "SET_TAB": return { ...state, tab: action.payload }; - case "SET_PERIOD": - return { ...state, period: action.payload }; case "SET_LOADING": return { ...state, isLoading: action.payload }; case "SET_ERROR": @@ -86,8 +80,6 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState { return { ...state, budgetYear: action.payload.year, budgetMonth: action.payload.month }; case "SET_BUDGET_VS_ACTUAL": 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": return { ...state, sourceId: action.payload }; case "SET_CATEGORY_TYPE": @@ -97,83 +89,84 @@ function reducer(state: ReportsState, action: ReportsAction): ReportsState { } } +/** @deprecated — see module-level comment. */ 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 fetchData = useCallback(async ( - tab: ReportTab, - period: DashboardPeriod, - budgetYear: number, - budgetMonth: number, - customFrom?: string, - customTo?: string, - srcId?: number | null, - catType?: CategoryTypeFilter, - ) => { - const fetchId = ++fetchIdRef.current; - dispatch({ type: "SET_LOADING", payload: true }); - dispatch({ type: "SET_ERROR", payload: null }); + const fetchData = useCallback( + async ( + tab: ReportTab, + dateFrom: string, + dateTo: string, + budgetYear: number, + budgetMonth: number, + srcId: number | null, + catType: CategoryTypeFilter, + ) => { + const fetchId = ++fetchIdRef.current; + dispatch({ type: "SET_LOADING", payload: true }); + dispatch({ type: "SET_ERROR", payload: null }); - try { - switch (tab) { - case "trends": { - const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined); - if (fetchId !== fetchIdRef.current) return; - dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); - break; - } - case "byCategory": { - const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined); - if (fetchId !== fetchIdRef.current) return; - dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); - break; - } - case "overTime": { - const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo); - const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined); - if (fetchId !== fetchIdRef.current) return; - dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); - break; - } - case "budgetVsActual": { - const data = await getBudgetVsActualData(budgetYear, budgetMonth); - if (fetchId !== fetchIdRef.current) return; - dispatch({ type: "SET_BUDGET_VS_ACTUAL", payload: data }); - break; + try { + switch (tab) { + case "trends": { + const data = await getMonthlyTrends(dateFrom, dateTo, srcId ?? undefined); + if (fetchId !== fetchIdRef.current) return; + dispatch({ type: "SET_MONTHLY_TRENDS", payload: data }); + break; + } + case "byCategory": { + const data = await getExpensesByCategory(dateFrom, dateTo, srcId ?? undefined); + if (fetchId !== fetchIdRef.current) return; + dispatch({ type: "SET_CATEGORY_SPENDING", payload: data }); + break; + } + case "overTime": { + const data = await getCategoryOverTime(dateFrom, dateTo, undefined, srcId ?? undefined, catType ?? undefined); + if (fetchId !== fetchIdRef.current) return; + dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data }); + break; + } + case "budgetVsActual": { + const data = await getBudgetVsActualData(budgetYear, budgetMonth); + 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(() => { - fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType); - }, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, state.sourceId, state.categoryType, fetchData]); + 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) => { dispatch({ type: "SET_TAB", payload: tab }); }, []); - const setPeriod = useCallback((period: DashboardPeriod) => { - dispatch({ type: "SET_PERIOD", payload: period }); - }, []); - const setBudgetMonth = useCallback((year: number, month: number) => { 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) => { dispatch({ type: "SET_SOURCE_ID", payload: id }); }, []); @@ -182,5 +175,12 @@ export function useReports() { dispatch({ type: "SET_CATEGORY_TYPE", payload: catType }); }, []); + const state = { + ...innerState, + period, + customDateFrom: from, + customDateTo: to, + }; + return { state, setTab, setPeriod, setCustomDates, setBudgetMonth, setSourceId, setCategoryType }; } diff --git a/src/hooks/useReportsPeriod.test.ts b/src/hooks/useReportsPeriod.test.ts new file mode 100644 index 0000000..f17fd01 --- /dev/null +++ b/src/hooks/useReportsPeriod.test.ts @@ -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"); + }); +}); diff --git a/src/hooks/useReportsPeriod.ts b/src/hooks/useReportsPeriod.ts new file mode 100644 index 0000000..aa1ae17 --- /dev/null +++ b/src/hooks/useReportsPeriod.ts @@ -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 }; +} diff --git a/src/hooks/useTrends.ts b/src/hooks/useTrends.ts new file mode 100644 index 0000000..8edecb8 --- /dev/null +++ b/src/hooks/useTrends.ts @@ -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 }; +}