Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 7 additions & 85 deletions src/components/logged-in/dashboard/dashboard-filters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
{DATE_PRESETS.filter((p) => p !== 'custom').map((preset) => (
<Button
key={preset}
variant={filters.preset === preset ? 'default' : 'outline'}
size="sm"
onClick={() => handlePresetClick(preset)}
>
{getPresetLabel(preset)}
</Button>
))}

<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant={filters.preset === 'custom' ? 'default' : 'outline'}
size="sm"
className={cn(
'min-w-[140px] justify-start text-left font-normal',
filters.preset !== 'custom' && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 size-4" />
{filters.preset === 'custom' && filters.from && filters.to ? (
`${format(filters.from, 'MMM d')} - ${format(filters.to, 'MMM d')}`
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
selected={selectedRange}
onSelect={handleDateSelect}
captionLayout="dropdown"
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
<DateRangeFilter
from={filters.from}
to={filters.to}
onFilterChange={setFilters}
/>
)
}
115 changes: 44 additions & 71 deletions src/components/logged-in/dashboard/search-params.ts
Original file line number Diff line number Diff line change
@@ -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<DatePreset, 'custom'> = '30d'

function getStoredPreset(): DatePreset {
function getStoredPreset(): Exclude<DatePreset, 'custom'> {
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<DatePreset, 'custom'>)) {
return stored as Exclude<DatePreset, 'custom'>
}
Comment on lines +28 to 31

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic filters to only include non-custom presets, but the validation uses 'includes' on this filtered array. This is correct, but the type assertion could be clearer. Additionally, stored values that were previously valid (like 'custom') would now be ignored and fall back to DEFAULT_PRESET without any migration or user notification.

Copilot uses AI. Check for mistakes.
} 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<DatePreset, 'custom'>): 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<DatePreset>(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])
Comment on lines 57 to +66

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the transactions filter, this useEffect includes setQueryFilters in its dependency array while also calling it inside the effect. This could create an infinite loop if the function is not guaranteed to be stable. The initializedRef.current guard should prevent this, but it's fragile - if the component re-mounts, the initialization would run again.

Copilot uses AI. Check for mistakes.

// Persist active preset to localStorage when dates change
const prevPresetRef = useRef<DatePreset | null>(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],
Expand All @@ -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' {
Expand Down
20 changes: 9 additions & 11 deletions src/components/logged-in/dashboard/spending-by-category.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand All @@ -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 },
)
Comment on lines +35 to +42

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty strings are passed for from and to when the filters are null. While the enabled flag prevents the query from running, this pattern is repeated across multiple components and could lead to issues if the enabled logic changes or if the parameters are validated elsewhere. Consider using a more explicit pattern like omitting the call entirely or using undefined.

Copilot uses AI. Check for mistakes.

if (isLoading) {
return (
Expand Down
23 changes: 11 additions & 12 deletions src/components/logged-in/dashboard/spending-over-time.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 },
Comment on lines +33 to +39

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty strings are passed for from and to when the filters are null. While the enabled flag prevents the query from running, this pattern is repeated across multiple components and could lead to issues if the enabled logic changes or if the parameters are validated elsewhere. Consider using a more explicit pattern like omitting the call entirely or using undefined.

Suggested change
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 },
const hasDates = !!filters.from && !!filters.to
const queryInput = hasDates
? {
from: format(filters.from!, 'yyyy-MM-dd'),
to: format(filters.to!, 'yyyy-MM-dd'),
groupBy,
}
: undefined
const { data, isLoading } = trpc.dashboard.getSpendingOverTime.useQuery(
queryInput,
{ enabled: hasDates },

Copilot uses AI. Check for mistakes.
)

if (isLoading) {
return (
Expand Down
18 changes: 8 additions & 10 deletions src/components/logged-in/dashboard/spending-summary-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
TagIcon,
TrendingUpIcon,
} from 'lucide-react'
import { getDateRangeFromPreset, useDashboardFilters } from './search-params'
import { useDashboardFilters } from './search-params'

function SummaryCardSkeleton() {
return (
Expand All @@ -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 },
)
Comment on lines +35 to +41

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty strings are passed for from and to when the filters are null. While the enabled flag prevents the query from running, this pattern is repeated across multiple components and could lead to issues if the enabled logic changes or if the parameters are validated elsewhere. Consider using a more explicit pattern like omitting the call entirely or using undefined.

Copilot uses AI. Check for mistakes.

if (isLoading) {
return (
Expand Down
Loading
Loading