diff --git a/src/components/logged-in/dashboard/dashboard-filters.tsx b/src/components/logged-in/dashboard/dashboard-filters.tsx index 168b72c..07056d7 100644 --- a/src/components/logged-in/dashboard/dashboard-filters.tsx +++ b/src/components/logged-in/dashboard/dashboard-filters.tsx @@ -1,94 +1,16 @@ 'use client' -import { Button } from '@/components/ui/button' -import { Calendar } from '@/components/ui/calendar' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { format } from 'date-fns' -import { CalendarIcon } from 'lucide-react' -import { useMemo, useState } from 'react' -import { - DATE_PRESETS, - type DatePreset, - getPresetLabel, - useDashboardFilters, -} from './search-params' +import { DateRangeFilter } from '@/components/logged-in/shared/date-range-filter' +import { useDashboardFilters } from './search-params' export function DashboardFilters() { const { filters, setFilters } = useDashboardFilters() - const [isCalendarOpen, setIsCalendarOpen] = useState(false) - - const selectedRange = useMemo( - () => ({ - from: filters.from ? new Date(filters.from) : undefined, - to: filters.to ? new Date(filters.to) : undefined, - }), - [filters.from, filters.to], - ) - - const handlePresetClick = (preset: DatePreset) => { - if (preset === 'custom') { - setIsCalendarOpen(true) - return - } - setFilters({ preset, from: null, to: null }) - } - - const handleDateSelect = (range: { from?: Date; to?: Date } | undefined) => { - if (range?.from) { - setFilters({ from: range.from, preset: 'custom' }) - } - if (range?.to) { - setFilters({ to: range.to, preset: 'custom' }) - setIsCalendarOpen(false) - } - } return ( -
- {DATE_PRESETS.filter((p) => p !== 'custom').map((preset) => ( - - ))} - - - - - - - - - -
+ ) } diff --git a/src/components/logged-in/dashboard/search-params.ts b/src/components/logged-in/dashboard/search-params.ts index 5ad9bbf..73e336d 100644 --- a/src/components/logged-in/dashboard/search-params.ts +++ b/src/components/logged-in/dashboard/search-params.ts @@ -1,68 +1,84 @@ +import { + DATE_PRESETS, + type DatePreset, + getActivePreset, + getDateRangeFromPreset, +} from '@/components/logged-in/shared/date-range-filter' import { parseAsLocalDate } from '@/lib/utils' -import { startOfYear, subDays, subMonths } from 'date-fns' -import { parseAsStringLiteral, useQueryStates } from 'nuqs' +import { useQueryStates } from 'nuqs' import { useCallback, useEffect, useRef } from 'react' -export const DATE_PRESETS = ['7d', '30d', '3m', '6m', 'ytd', 'custom'] as const -export type DatePreset = (typeof DATE_PRESETS)[number] +export { + DATE_PRESETS, + type DatePreset, + getActivePreset, + getDateRangeFromPreset, +} const STORAGE_KEY = 'dashboard-date-preset' -const DEFAULT_PRESET: DatePreset = '30d' +const DEFAULT_PRESET: Exclude = '30d' -function getStoredPreset(): DatePreset { +function getStoredPreset(): Exclude { if (typeof window === 'undefined') return DEFAULT_PRESET try { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return DEFAULT_PRESET - if (DATE_PRESETS.includes(stored as DatePreset)) { - return stored as DatePreset + const validPresets = DATE_PRESETS.filter((p) => p !== 'custom') + if (validPresets.includes(stored as Exclude)) { + return stored as Exclude } } catch { - // localStorage unavailable or blocked, fall through to default + // localStorage unavailable or blocked } return DEFAULT_PRESET } -function setStoredPreset(preset: DatePreset): void { +function setStoredPreset(preset: Exclude): void { if (typeof window === 'undefined') return try { localStorage.setItem(STORAGE_KEY, preset) } catch { - // localStorage unavailable or blocked, silently fail + // localStorage unavailable or blocked } } export function useDashboardFilters() { - const initialPreset = getStoredPreset() - const storedPresetRef = useRef(initialPreset) + const initializedRef = useRef(false) const [filters, setQueryFilters] = useQueryStates({ - preset: parseAsStringLiteral(DATE_PRESETS).withDefault(initialPreset), from: parseAsLocalDate, to: parseAsLocalDate, }) - // Sync external URL changes (browser navigation) to localStorage + // Initialize from localStorage on first render if URL has no dates useEffect(() => { - if (filters.preset !== storedPresetRef.current) { - setStoredPreset(filters.preset) - storedPresetRef.current = filters.preset + if (initializedRef.current) return + initializedRef.current = true + + if (!filters.from && !filters.to) { + const storedPreset = getStoredPreset() + const range = getDateRangeFromPreset(storedPreset) + setQueryFilters({ from: range.from, to: range.to }) } - }, [filters.preset]) + }, [filters.from, filters.to, setQueryFilters]) + + // Persist active preset to localStorage when dates change + const prevPresetRef = useRef(null) + useEffect(() => { + if (!filters.from || !filters.to) return + + const activePreset = getActivePreset(filters.from, filters.to) + if (activePreset !== 'custom' && activePreset !== prevPresetRef.current) { + setStoredPreset(activePreset) + prevPresetRef.current = activePreset + } + }, [filters.from, filters.to]) const setFilters = useCallback( - (updates: { - preset?: DatePreset - from?: Date | null - to?: Date | null - }) => { - if (updates.preset !== undefined) { - setStoredPreset(updates.preset) - storedPresetRef.current = updates.preset - } + (updates: { from: Date; to: Date }) => { return setQueryFilters(updates) }, [setQueryFilters], @@ -71,49 +87,6 @@ export function useDashboardFilters() { return { filters, setFilters } } -export function getDateRangeFromPreset( - preset: DatePreset, - customFrom?: Date | null, - customTo?: Date | null, -): { from: Date; to: Date } { - const now = new Date() - - switch (preset) { - case '7d': - return { from: subDays(now, 7), to: now } - case '30d': - return { from: subDays(now, 30), to: now } - case '3m': - return { from: subMonths(now, 3), to: now } - case '6m': - return { from: subMonths(now, 6), to: now } - case 'ytd': - return { from: startOfYear(now), to: now } - case 'custom': - return { - from: customFrom ?? subDays(now, 30), - to: customTo ?? now, - } - } -} - -export function getPresetLabel(preset: DatePreset): string { - switch (preset) { - case '7d': - return 'Last 7 days' - case '30d': - return 'Last 30 days' - case '3m': - return 'Last 3 months' - case '6m': - return 'Last 6 months' - case 'ytd': - return 'Year to date' - case 'custom': - return 'Custom' - } -} - export function getGroupByFromPreset( preset: DatePreset, ): 'day' | 'week' | 'month' { diff --git a/src/components/logged-in/dashboard/spending-by-category.tsx b/src/components/logged-in/dashboard/spending-by-category.tsx index 55f94d2..40f90d4 100644 --- a/src/components/logged-in/dashboard/spending-by-category.tsx +++ b/src/components/logged-in/dashboard/spending-by-category.tsx @@ -12,7 +12,7 @@ import { trpc } from '@/lib/trpc/client' import { formatCurrency } from '@/lib/utils' import { format } from 'date-fns' import { Bar, BarChart, Cell, XAxis, YAxis } from 'recharts' -import { getDateRangeFromPreset, useDashboardFilters } from './search-params' +import { useDashboardFilters } from './search-params' const CHART_COLORS = [ 'var(--chart-1)', @@ -31,17 +31,15 @@ const CHART_COLORS = [ export function SpendingByCategory() { const { filters } = useDashboardFilters() - const { from, to } = getDateRangeFromPreset( - filters.preset, - filters.from, - filters.to, - ) - const { data, isLoading } = trpc.dashboard.getSpendingByCategory.useQuery({ - from: format(from, 'yyyy-MM-dd'), - to: format(to, 'yyyy-MM-dd'), - limit: 8, - }) + const { data, isLoading } = trpc.dashboard.getSpendingByCategory.useQuery( + { + from: filters.from ? format(filters.from, 'yyyy-MM-dd') : '', + to: filters.to ? format(filters.to, 'yyyy-MM-dd') : '', + limit: 8, + }, + { enabled: !!filters.from && !!filters.to }, + ) if (isLoading) { return ( diff --git a/src/components/logged-in/dashboard/spending-over-time.tsx b/src/components/logged-in/dashboard/spending-over-time.tsx index 1a54dcb..5116cfe 100644 --- a/src/components/logged-in/dashboard/spending-over-time.tsx +++ b/src/components/logged-in/dashboard/spending-over-time.tsx @@ -13,7 +13,7 @@ import { formatCurrency } from '@/lib/utils' import { format, parseISO } from 'date-fns' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { - getDateRangeFromPreset, + getActivePreset, getGroupByFromPreset, useDashboardFilters, } from './search-params' @@ -27,18 +27,17 @@ const chartConfig = { export function SpendingOverTime() { const { filters } = useDashboardFilters() - const { from, to } = getDateRangeFromPreset( - filters.preset, - filters.from, - filters.to, - ) - const groupBy = getGroupByFromPreset(filters.preset) + const activePreset = getActivePreset(filters.from, filters.to) + const groupBy = getGroupByFromPreset(activePreset) - const { data, isLoading } = trpc.dashboard.getSpendingOverTime.useQuery({ - from: format(from, 'yyyy-MM-dd'), - to: format(to, 'yyyy-MM-dd'), - groupBy, - }) + const { data, isLoading } = trpc.dashboard.getSpendingOverTime.useQuery( + { + from: filters.from ? format(filters.from, 'yyyy-MM-dd') : '', + to: filters.to ? format(filters.to, 'yyyy-MM-dd') : '', + groupBy, + }, + { enabled: !!filters.from && !!filters.to }, + ) if (isLoading) { return ( diff --git a/src/components/logged-in/dashboard/spending-summary-cards.tsx b/src/components/logged-in/dashboard/spending-summary-cards.tsx index d25f5c5..179a8e5 100644 --- a/src/components/logged-in/dashboard/spending-summary-cards.tsx +++ b/src/components/logged-in/dashboard/spending-summary-cards.tsx @@ -11,7 +11,7 @@ import { TagIcon, TrendingUpIcon, } from 'lucide-react' -import { getDateRangeFromPreset, useDashboardFilters } from './search-params' +import { useDashboardFilters } from './search-params' function SummaryCardSkeleton() { return ( @@ -30,17 +30,15 @@ function SummaryCardSkeleton() { export function SpendingSummaryCards() { const { filters } = useDashboardFilters() - const { from, to } = getDateRangeFromPreset( - filters.preset, - filters.from, - filters.to, - ) const { data: summary, isLoading } = - trpc.dashboard.getSpendingSummary.useQuery({ - from: format(from, 'yyyy-MM-dd'), - to: format(to, 'yyyy-MM-dd'), - }) + trpc.dashboard.getSpendingSummary.useQuery( + { + from: filters.from ? format(filters.from, 'yyyy-MM-dd') : '', + to: filters.to ? format(filters.to, 'yyyy-MM-dd') : '', + }, + { enabled: !!filters.from && !!filters.to }, + ) if (isLoading) { return ( diff --git a/src/components/logged-in/dashboard/top-merchants.tsx b/src/components/logged-in/dashboard/top-merchants.tsx index 600b909..5a343b5 100644 --- a/src/components/logged-in/dashboard/top-merchants.tsx +++ b/src/components/logged-in/dashboard/top-merchants.tsx @@ -4,7 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { trpc } from '@/lib/trpc/client' import { formatCurrency } from '@/lib/utils' import { format } from 'date-fns' -import { getDateRangeFromPreset, useDashboardFilters } from './search-params' +import { useDashboardFilters } from './search-params' function MerchantSkeleton() { return ( @@ -20,17 +20,15 @@ function MerchantSkeleton() { export function TopMerchants() { const { filters } = useDashboardFilters() - const { from, to } = getDateRangeFromPreset( - filters.preset, - filters.from, - filters.to, - ) - const { data, isLoading } = trpc.dashboard.getTopMerchants.useQuery({ - from: format(from, 'yyyy-MM-dd'), - to: format(to, 'yyyy-MM-dd'), - limit: 5, - }) + const { data, isLoading } = trpc.dashboard.getTopMerchants.useQuery( + { + from: filters.from ? format(filters.from, 'yyyy-MM-dd') : '', + to: filters.to ? format(filters.to, 'yyyy-MM-dd') : '', + limit: 5, + }, + { enabled: !!filters.from && !!filters.to }, + ) return ( diff --git a/src/components/logged-in/dashboard/transactions-to-review.tsx b/src/components/logged-in/dashboard/transactions-to-review.tsx index 8083004..8276c09 100644 --- a/src/components/logged-in/dashboard/transactions-to-review.tsx +++ b/src/components/logged-in/dashboard/transactions-to-review.tsx @@ -7,7 +7,7 @@ import { formatCurrency } from '@/lib/utils' import { format, parseISO } from 'date-fns' import { CheckCircleIcon } from 'lucide-react' import Link from 'next/link' -import { getDateRangeFromPreset, useDashboardFilters } from './search-params' +import { useDashboardFilters } from './search-params' function TransactionSkeleton() { return ( @@ -23,17 +23,15 @@ function TransactionSkeleton() { export function TransactionsToReview() { const { filters } = useDashboardFilters() - const { from, to } = getDateRangeFromPreset( - filters.preset, - filters.from, - filters.to, - ) - const { data, isLoading } = trpc.dashboard.getTransactionsToReview.useQuery({ - from: format(from, 'yyyy-MM-dd'), - to: format(to, 'yyyy-MM-dd'), - limit: 5, - }) + const { data, isLoading } = trpc.dashboard.getTransactionsToReview.useQuery( + { + from: filters.from ? format(filters.from, 'yyyy-MM-dd') : '', + to: filters.to ? format(filters.to, 'yyyy-MM-dd') : '', + limit: 5, + }, + { enabled: !!filters.from && !!filters.to }, + ) return ( diff --git a/src/components/logged-in/shared/date-range-filter.tsx b/src/components/logged-in/shared/date-range-filter.tsx new file mode 100644 index 0000000..4d98766 --- /dev/null +++ b/src/components/logged-in/shared/date-range-filter.tsx @@ -0,0 +1,157 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { format, isSameDay, startOfYear, subDays, subMonths } from 'date-fns' +import { CalendarIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { DateRange } from 'react-day-picker' + +export const DATE_PRESETS = ['7d', '30d', '3m', '6m', 'ytd', 'custom'] as const +export type DatePreset = (typeof DATE_PRESETS)[number] + +export function getPresetLabel(preset: DatePreset): string { + switch (preset) { + case '7d': + return 'Last 7 days' + case '30d': + return 'Last 30 days' + case '3m': + return 'Last 3 months' + case '6m': + return 'Last 6 months' + case 'ytd': + return 'Year to date' + case 'custom': + return 'Custom' + } +} + +export function getDateRangeFromPreset(preset: Exclude): { + from: Date + to: Date +} { + const now = new Date() + + switch (preset) { + case '7d': + return { from: subDays(now, 7), to: now } + case '30d': + return { from: subDays(now, 30), to: now } + case '3m': + return { from: subMonths(now, 3), to: now } + case '6m': + return { from: subMonths(now, 6), to: now } + case 'ytd': + return { from: startOfYear(now), to: now } + } +} + +export function getActivePreset( + from: Date | null, + to: Date | null, +): DatePreset { + if (!from || !to) return '30d' + + const presets = ['7d', '30d', '3m', '6m', 'ytd'] as const + for (const preset of presets) { + const range = getDateRangeFromPreset(preset) + if (isSameDay(from, range.from) && isSameDay(to, range.to)) { + return preset + } + } + + return 'custom' +} + +interface DateRangeFilterProps { + from: Date | null + to: Date | null + onFilterChange: (updates: { from: Date; to: Date }) => void +} + +export function DateRangeFilter({ + from, + to, + onFilterChange, +}: DateRangeFilterProps) { + const [isCalendarOpen, setIsCalendarOpen] = useState(false) + const [localRange, setLocalRange] = useState() + const [clickCount, setClickCount] = useState(0) + + const activePreset = useMemo(() => getActivePreset(from, to), [from, to]) + + const handleOpenChange = (open: boolean) => { + setIsCalendarOpen(open) + if (open) { + setClickCount(0) + setLocalRange( + from + ? { from: new Date(from), to: to ? new Date(to) : undefined } + : undefined, + ) + } + } + + const handlePresetClick = (preset: Exclude) => { + const range = getDateRangeFromPreset(preset) + onFilterChange(range) + } + + const handleDateSelect = (range: DateRange | undefined) => { + if (!range?.from) return + const newClickCount = clickCount + 1 + setClickCount(newClickCount) + setLocalRange(range) + if (newClickCount >= 2 && range.to && !isSameDay(range.from, range.to)) { + onFilterChange({ from: range.from, to: range.to }) + setIsCalendarOpen(false) + } + } + + return ( +
+ {DATE_PRESETS.filter((p) => p !== 'custom').map((presetOption) => ( + + ))} + + + + + + + + + +
+ ) +} diff --git a/src/components/logged-in/transactions/filters/date-range-filter.tsx b/src/components/logged-in/transactions/filters/date-range-filter.tsx index c3f097b..3d8decd 100644 --- a/src/components/logged-in/transactions/filters/date-range-filter.tsx +++ b/src/components/logged-in/transactions/filters/date-range-filter.tsx @@ -1,116 +1,23 @@ 'use client' -import { Button } from '@/components/ui/button' -import { Calendar } from '@/components/ui/calendar' -import { Kbd } from '@/components/ui/kbd' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' +import { DateRangeFilter as SharedDateRangeFilter } from '@/components/logged-in/shared/date-range-filter' import { usePagination } from '@/hooks/use-pagination' -import { cn } from '@/lib/utils' -import { format } from 'date-fns' -import { CalendarIcon, XIcon } from 'lucide-react' -import { useState } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' import { useTransactionsFilters } from './search-params' -const DATE_RANGE_SHORTCUT = 'D' - export function DateRangeFilter() { const { transactionFilters, setTransactionFilters } = useTransactionsFilters() const { setPagination } = usePagination('transactions') - const [isOpen, setIsOpen] = useState(false) - - const hasValues = !!transactionFilters.from && !!transactionFilters.to - const handleClear = (e: React.MouseEvent) => { - e.stopPropagation() // Prevent popover from opening - setTransactionFilters({ - from: null, - to: null, - }) + const handleFilterChange = (updates: { from: Date; to: Date }) => { + setTransactionFilters(updates) setPagination({ page: 1 }) } - useHotkeys(DATE_RANGE_SHORTCUT, (e) => { - e.preventDefault() - setIsOpen(!isOpen) - }) - return ( - - - - - - ) : ( - - )} - - - - - - { - if (value?.from) { - setTransactionFilters({ - from: value.from, - }) - } - if (value?.to) { - setTransactionFilters({ - to: value.to, - }) - } - setPagination({ page: 1 }) - }} - captionLayout="dropdown" - /> - - - - Or press {DATE_RANGE_SHORTCUT} to pick a - date range - - + ) } diff --git a/src/components/logged-in/transactions/filters/index.tsx b/src/components/logged-in/transactions/filters/index.tsx index 6231ade..f5a41b8 100644 --- a/src/components/logged-in/transactions/filters/index.tsx +++ b/src/components/logged-in/transactions/filters/index.tsx @@ -11,9 +11,9 @@ import { UploadFilter } from './upload-filter' export function TransactionsFilters() { return ( <> -
+ +
-
diff --git a/src/components/logged-in/transactions/filters/search-params.ts b/src/components/logged-in/transactions/filters/search-params.ts index df1318e..e910d2f 100644 --- a/src/components/logged-in/transactions/filters/search-params.ts +++ b/src/components/logged-in/transactions/filters/search-params.ts @@ -1,8 +1,47 @@ +import { + DATE_PRESETS, + type DatePreset, + getActivePreset, + getDateRangeFromPreset, +} from '@/components/logged-in/shared/date-range-filter' import { parseAsLocalDate } from '@/lib/utils' import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs' +import { useCallback, useEffect, useRef } from 'react' + +const STORAGE_KEY = 'transactions-date-preset' +const DEFAULT_PRESET: Exclude = '30d' + +function getStoredPreset(): Exclude { + if (typeof window === 'undefined') return DEFAULT_PRESET + + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (!stored) return DEFAULT_PRESET + + const validPresets = DATE_PRESETS.filter((p) => p !== 'custom') + if (validPresets.includes(stored as Exclude)) { + return stored as Exclude + } + } catch { + // localStorage unavailable or blocked + } + + return DEFAULT_PRESET +} + +function setStoredPreset(preset: Exclude): void { + if (typeof window === 'undefined') return + try { + localStorage.setItem(STORAGE_KEY, preset) + } catch { + // localStorage unavailable or blocked + } +} export function useTransactionsFilters() { - const [transactionFilters, setTransactionFilters] = useQueryStates({ + const initializedRef = useRef(false) + + const [transactionFilters, setQueryFilters] = useQueryStates({ category: parseAsString.withDefault('all'), from: parseAsLocalDate, to: parseAsLocalDate, @@ -11,6 +50,49 @@ export function useTransactionsFilters() { uploadId: parseAsString.withDefault('all'), }) + // Initialize from localStorage on first render if URL has no dates + useEffect(() => { + if (initializedRef.current) return + initializedRef.current = true + + if (!transactionFilters.from && !transactionFilters.to) { + const storedPreset = getStoredPreset() + const range = getDateRangeFromPreset(storedPreset) + setQueryFilters({ from: range.from, to: range.to }) + } + }, [transactionFilters.from, transactionFilters.to, setQueryFilters]) + + // Persist active preset to localStorage when dates change + const prevPresetRef = useRef(null) + useEffect(() => { + if (!transactionFilters.from || !transactionFilters.to) return + + const activePreset = getActivePreset( + transactionFilters.from, + transactionFilters.to, + ) + if (activePreset !== 'custom' && activePreset !== prevPresetRef.current) { + setStoredPreset(activePreset) + prevPresetRef.current = activePreset + } + }, [transactionFilters.from, transactionFilters.to]) + + const setTransactionFilters = useCallback( + ( + updates: Partial<{ + category: string | null + from: Date | null + to: Date | null + query: string | null + ids: string[] | null + uploadId: string | null + }>, + ) => { + return setQueryFilters(updates) + }, + [setQueryFilters], + ) + return { transactionFilters, setTransactionFilters,