feat: add custom date range picker to reports and dashboard

Allow users to select an arbitrary date interval (e.g. Jan 1–15) in
addition to the existing preset periods. The "Custom" button opens a
dropdown with two date inputs and an Apply button. Works on both the
Reports and Dashboard pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-02-21 09:46:32 -05:00
parent 3434c9e11f
commit 446f6effab
6 changed files with 203 additions and 27 deletions

View file

@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Calendar } from "lucide-react";
import type { DashboardPeriod } from "../../shared/types";
const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "all"];
@ -6,17 +8,59 @@ const PERIODS: DashboardPeriod[] = ["month", "3months", "6months", "12months", "
interface PeriodSelectorProps {
value: DashboardPeriod;
onChange: (period: DashboardPeriod) => void;
customDateFrom?: string;
customDateTo?: string;
onCustomDateChange?: (dateFrom: string, dateTo: string) => void;
}
export default function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
export default function PeriodSelector({
value,
onChange,
customDateFrom,
customDateTo,
onCustomDateChange,
}: PeriodSelectorProps) {
const { t } = useTranslation();
const [showCustom, setShowCustom] = useState(false);
const [localFrom, setLocalFrom] = useState(customDateFrom ?? "");
const [localTo, setLocalTo] = useState(customDateTo ?? "");
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (customDateFrom) setLocalFrom(customDateFrom);
if (customDateTo) setLocalTo(customDateTo);
}, [customDateFrom, customDateTo]);
// Close panel on outside click
useEffect(() => {
if (!showCustom) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setShowCustom(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [showCustom]);
const handleApply = () => {
if (localFrom && localTo && localFrom <= localTo && onCustomDateChange) {
onCustomDateChange(localFrom, localTo);
setShowCustom(false);
}
};
const isValid = localFrom && localTo && localFrom <= localTo;
return (
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2 items-center relative">
{PERIODS.map((p) => (
<button
key={p}
onClick={() => onChange(p)}
onClick={() => {
onChange(p);
setShowCustom(false);
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
p === value
? "bg-[var(--primary)] text-white"
@ -26,6 +70,60 @@ export default function PeriodSelector({ value, onChange }: PeriodSelectorProps)
{t(`dashboard.period.${p}`)}
</button>
))}
{onCustomDateChange && (
<div ref={panelRef} className="relative">
<button
onClick={() => setShowCustom((prev) => !prev)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors inline-flex items-center gap-1.5 ${
value === "custom"
? "bg-[var(--primary)] text-white"
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
}`}
>
<Calendar size={14} />
{t("dashboard.period.custom")}
</button>
{showCustom && (
<div className="absolute right-0 top-full mt-2 z-50 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg p-4 flex flex-col gap-3 min-w-[280px]">
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-[var(--muted-foreground)]">
{t("dashboard.dateFrom")}
</label>
<input
type="date"
value={localFrom}
onChange={(e) => setLocalFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-[var(--muted-foreground)]">
{t("dashboard.dateTo")}
</label>
<input
type="date"
value={localTo}
onChange={(e) => setLocalTo(e.target.value)}
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
/>
</div>
<button
onClick={handleApply}
disabled={!isValid}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isValid
? "bg-[var(--primary)] text-white hover:opacity-90"
: "bg-[var(--muted)] text-[var(--muted-foreground)] cursor-not-allowed"
}`}
>
{t("dashboard.apply")}
</button>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -16,6 +16,8 @@ interface DashboardState {
categoryBreakdown: CategoryBreakdownItem[];
recentTransactions: RecentTransaction[];
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
isLoading: boolean;
error: string | null;
}
@ -31,13 +33,20 @@ type DashboardAction =
recentTransactions: RecentTransaction[];
};
}
| { type: "SET_PERIOD"; payload: DashboardPeriod };
| { 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 monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: DashboardState = {
summary: { totalCount: 0, totalAmount: 0, incomeTotal: 0, expenseTotal: 0 },
categoryBreakdown: [],
recentTransactions: [],
period: "month",
customDateFrom: monthStartStr,
customDateTo: todayStr,
isLoading: false,
error: null,
};
@ -58,13 +67,22 @@ function reducer(state: DashboardState, action: DashboardAction): DashboardState
};
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): { dateFrom?: string; dateTo?: string } {
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();
@ -87,6 +105,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
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")}`;
@ -98,13 +119,13 @@ export function useDashboard() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const fetchData = useCallback(async (period: DashboardPeriod) => {
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);
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const [summary, categoryBreakdown, recentTransactions] = await Promise.all([
getDashboardSummary(dateFrom, dateTo),
getExpensesByCategory(dateFrom, dateTo),
@ -123,12 +144,16 @@ export function useDashboard() {
}, []);
useEffect(() => {
fetchData(state.period);
}, [state.period, fetchData]);
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 });
}, []);
return { state, setPeriod };
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
return { state, setPeriod, setCustomDates };
}

View file

@ -14,6 +14,8 @@ import { getBudgetVsActualData } from "../services/budgetService";
interface ReportsState {
tab: ReportTab;
period: DashboardPeriod;
customDateFrom: string;
customDateTo: string;
monthlyTrends: MonthlyTrendItem[];
categorySpending: CategoryBreakdownItem[];
categoryOverTime: CategoryOverTimeData;
@ -33,13 +35,18 @@ type ReportsAction =
| { type: "SET_CATEGORY_SPENDING"; payload: CategoryBreakdownItem[] }
| { 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_BUDGET_VS_ACTUAL"; payload: BudgetVsActualRow[] }
| { 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 monthStartStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const initialState: ReportsState = {
tab: "trends",
period: "6months",
customDateFrom: monthStartStr,
customDateTo: todayStr,
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
@ -70,13 +77,22 @@ 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 };
default:
return state;
}
}
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
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();
@ -99,6 +115,9 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
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")}`;
@ -115,6 +134,8 @@ export function useReports() {
period: DashboardPeriod,
budgetYear: number,
budgetMonth: number,
customFrom?: string,
customTo?: string,
) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
@ -123,21 +144,21 @@ export function useReports() {
try {
switch (tab) {
case "trends": {
const { dateFrom, dateTo } = computeDateRange(period);
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getMonthlyTrends(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_MONTHLY_TRENDS", payload: data });
break;
}
case "byCategory": {
const { dateFrom, dateTo } = computeDateRange(period);
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getExpensesByCategory(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_SPENDING", payload: data });
break;
}
case "overTime": {
const { dateFrom, dateTo } = computeDateRange(period);
const { dateFrom, dateTo } = computeDateRange(period, customFrom, customTo);
const data = await getCategoryOverTime(dateFrom, dateTo);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_CATEGORY_OVER_TIME", payload: data });
@ -160,8 +181,8 @@ export function useReports() {
}, []);
useEffect(() => {
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, fetchData]);
fetchData(state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo);
}, [state.tab, state.period, state.budgetYear, state.budgetMonth, state.customDateFrom, state.customDateTo, fetchData]);
const setTab = useCallback((tab: ReportTab) => {
dispatch({ type: "SET_TAB", payload: tab });
@ -184,5 +205,9 @@ export function useReports() {
dispatch({ type: "SET_BUDGET_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.budgetYear, state.budgetMonth]);
return { state, setTab, setPeriod, navigateBudgetMonth };
const setCustomDates = useCallback((dateFrom: string, dateTo: string) => {
dispatch({ type: "SET_CUSTOM_DATES", payload: { dateFrom, dateTo } });
}, []);
return { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth };
}

View file

@ -11,8 +11,15 @@ import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
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();
@ -24,6 +31,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 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 };
@ -31,7 +39,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
export default function DashboardPage() {
const { t } = useTranslation();
const { state, setPeriod } = useDashboard();
const { state, setPeriod, setCustomDates } = useDashboard();
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
@ -81,7 +89,7 @@ export default function DashboardPage() {
},
];
const { dateFrom, dateTo } = computeDateRange(period);
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
@ -90,7 +98,13 @@ export default function DashboardPage() {
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" />
</div>
<PeriodSelector value={period} onChange={setPeriod} />
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={state.customDateFrom}
customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">

View file

@ -13,8 +13,15 @@ import TransactionDetailModal from "../components/shared/TransactionDetailModal"
const TABS: ReportTab[] = ["trends", "byCategory", "overTime", "budgetVsActual"];
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
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();
@ -26,6 +33,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 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 };
@ -33,7 +41,7 @@ function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?
export default function ReportsPage() {
const { t } = useTranslation();
const { state, setTab, setPeriod, navigateBudgetMonth } = useReports();
const { state, setTab, setPeriod, setCustomDates, navigateBudgetMonth } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
@ -53,7 +61,7 @@ export default function ReportsPage() {
setDetailModal(item);
}, []);
const { dateFrom, dateTo } = computeDateRange(state.period);
const { dateFrom, dateTo } = computeDateRange(state.period, state.customDateFrom, state.customDateTo);
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
@ -69,7 +77,13 @@ export default function ReportsPage() {
onNavigate={navigateBudgetMonth}
/>
) : (
<PeriodSelector value={state.period} onChange={setPeriod} />
<PeriodSelector
value={state.period}
onChange={setPeriod}
customDateFrom={state.customDateFrom}
customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates}
/>
)}
</div>

View file

@ -247,7 +247,7 @@ export interface ImportReport {
// --- Dashboard Types ---
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all";
export type DashboardPeriod = "month" | "3months" | "6months" | "12months" | "all" | "custom";
export interface DashboardSummary {
totalCount: number;