-
Notifications
You must be signed in to change notification settings - Fork 3
claude/shared-date-range-filter-IAWME #116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8efe706
24c4c75
959e4c5
6e73266
ca9fef6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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} | ||
| /> | ||
| ) | ||
| } |
| 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'> | ||
| } | ||
| } 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
|
||
|
|
||
| // 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], | ||
|
|
@@ -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' { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
| ) | ||
|
Comment on lines
+35
to
+42
|
||
|
|
||
| if (isLoading) { | ||
| return ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 }, | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+39
|
||||||||||||||||||||||||||||||||||||||||
| 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 }, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
| ) | ||
|
Comment on lines
+35
to
+41
|
||
|
|
||
| if (isLoading) { | ||
| return ( | ||
|
|
||
There was a problem hiding this comment.
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.