Simpl-Resultat/src/hooks/useDashboard.ts
le king fu 0bbbcc541b Revamp dashboard: YTD default, expenses chart, budget table
- Default period changed from month to year-to-date
- Remove recent transactions section
- Add expenses over time stacked bar chart (by category/month)
- Add budget vs actual table (current month)
- Reorganize layout: cards, pie + budget table, full-width chart

Closes #15

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 09:15:04 -05:00

171 lines
5.5 KiB
TypeScript

import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
DashboardPeriod,
DashboardSummary,
CategoryBreakdownItem,
CategoryOverTimeData,
BudgetVsActualRow,
} from "../shared/types";
import {
getDashboardSummary,
getExpensesByCategory,
} from "../services/dashboardService";
import { getCategoryOverTime } from "../services/reportService";
import { getBudgetVsActualData } from "../services/budgetService";
interface DashboardState {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
isLoading: boolean;
error: string | null;
}
type DashboardAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| {
type: "SET_DATA";
payload: {
summary: DashboardSummary;
categoryBreakdown: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
budgetVsActual: BudgetVsActualRow[];
};
}
| { type: "SET_PERIOD"; payload: DashboardPeriod }
| { type: "SET_CUSTOM_DATES"; payload: { dateFrom: string; dateTo: string } };
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const yearStartStr = `${now.getFullYear()}-01-01`;
const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
budgetVsActual: [],
period: "year",
customDateFrom: yearStartStr,
customDateTo: todayStr,
isLoading: false,
error: null,
};
function reducer(state: DashboardState, action: DashboardAction): DashboardState {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload, isLoading: false };
case "SET_DATA":
return {
...state,
summary: action.payload.summary,
categoryBreakdown: action.payload.categoryBreakdown,
categoryOverTime: action.payload.categoryOverTime,
budgetVsActual: action.payload.budgetVsActual,
isLoading: false,
};
case "SET_PERIOD":
return { ...state, period: action.payload };
case "SET_CUSTOM_DATES":
return { ...state, period: "custom" as DashboardPeriod, customDateFrom: action.payload.dateFrom, customDateTo: action.payload.dateTo };
default:
return state;
}
}
function computeDateRange(
period: DashboardPeriod,
customDateFrom?: string,
customDateTo?: string,
): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
if (period === "custom" && customDateFrom && customDateTo) {
return { dateFrom: customDateFrom, dateTo: customDateTo };
}
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month":
from = new Date(year, month, 1);
break;
case "3months":
from = new Date(year, month - 2, 1);
break;
case "6months":
from = new Date(year, month - 5, 1);
break;
case "year":
from = new Date(year, 0, 1);
break;
case "12months":
from = new Date(year, month - 11, 1);
break;
default:
from = new Date(year, month, 1);
break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export function useDashboard() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (period: DashboardPeriod, customFrom?: string, customTo?: string) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
const [summary, categoryBreakdown, categoryOverTime, budgetVsActual] = await Promise.all([
getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo),
getCategoryOverTime(dateFrom, dateTo),
getBudgetVsActualData(currentYear, currentMonth),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { summary, categoryBreakdown, categoryOverTime, budgetVsActual } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
fetchData(state.period, state.customDateFrom, state.customDateTo);
}, [state.period, state.customDateFrom, state.customDateTo, fetchData]);
const setPeriod = useCallback((period: DashboardPeriod) => {
dispatch({ type: "SET_PERIOD", payload: period });
}, []);
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
return { state, setPeriod, setCustomDates };
}