Skip to content

Commit bd80c39

Browse files
committed
feat(analytics): implement date filtering for visitor analytics
- Added a date filter feature to the VisitorGlobe component, allowing users to select predefined date ranges (Today, Yesterday, Last 7 Days, Last 15 Days, Last 30 Days) or a custom date range. - Introduced a new state management for date filters and updated the visitor data query to reflect the selected date range. - Enhanced the UI with quick filter buttons for desktop and a dropdown for mobile, improving user experience in navigating visitor data over time. This commit significantly enhances the analytics capabilities by providing flexible date filtering options for visitor data visualization.
1 parent dd33166 commit bd80c39

File tree

1 file changed

+183
-11
lines changed

1 file changed

+183
-11
lines changed

web/src/components/analytics/VisitorGlobe.tsx

Lines changed: 183 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ import type { ProjectResponse, VisitorInfo } from '@/api/client/types.gen'
88
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
99
import { Badge } from '@/components/ui/badge'
1010
import { Button } from '@/components/ui/button'
11+
import { Calendar } from '@/components/ui/calendar'
1112
import {
1213
Popover,
1314
PopoverContent,
1415
PopoverTrigger,
1516
} from '@/components/ui/popover'
17+
import {
18+
Select,
19+
SelectContent,
20+
SelectItem,
21+
SelectTrigger,
22+
SelectValue,
23+
} from '@/components/ui/select'
1624
import {
1725
Users,
1826
ArrowLeft,
@@ -21,8 +29,12 @@ import {
2129
Monitor,
2230
Clock,
2331
FileText,
32+
Calendar as CalendarIcon,
2433
} from 'lucide-react'
2534
import { useNavigate } from 'react-router-dom'
35+
import { format, subDays } from 'date-fns'
36+
import type { DateRange } from 'react-day-picker'
37+
import { cn } from '@/lib/utils'
2638
import { EarthGlobe, type ProjectedMarker } from './EarthGlobe'
2739

2840
interface VisitorGlobePageProps {
@@ -379,6 +391,80 @@ function RecentVisitorItem({ visitor, projectSlug }: RecentVisitorItemProps) {
379391
)
380392
}
381393

394+
// ─── Date filter types ───────────────────────────────────────────
395+
396+
const GLOBE_QUICK_FILTERS = [
397+
{ label: 'Today', value: 'today' },
398+
{ label: 'Yesterday', value: 'yesterday' },
399+
{ label: 'Last 7 Days', value: '7days' },
400+
{ label: 'Last 15 Days', value: '15days' },
401+
{ label: 'Last 30 Days', value: '30days' },
402+
{ label: 'Custom', value: 'custom' },
403+
] as const
404+
405+
type GlobeQuickFilter = (typeof GLOBE_QUICK_FILTERS)[number]['value']
406+
407+
interface GlobeDateFilter {
408+
quickFilter: GlobeQuickFilter
409+
dateRange: DateRange | undefined
410+
}
411+
412+
function resolveGlobeDateRange(filter: GlobeDateFilter): {
413+
startDate: Date
414+
endDate: Date
415+
} {
416+
const now = new Date()
417+
418+
if (filter.quickFilter === 'custom' && filter.dateRange?.from) {
419+
return {
420+
startDate: filter.dateRange.from,
421+
endDate: filter.dateRange.to ?? filter.dateRange.from,
422+
}
423+
}
424+
425+
switch (filter.quickFilter) {
426+
case 'today': {
427+
const start = new Date(now)
428+
start.setHours(0, 0, 0, 0)
429+
return { startDate: start, endDate: now }
430+
}
431+
case 'yesterday': {
432+
const yesterday = new Date(now)
433+
yesterday.setDate(yesterday.getDate() - 1)
434+
const start = new Date(yesterday)
435+
start.setHours(0, 0, 0, 0)
436+
const end = new Date(yesterday)
437+
end.setHours(23, 59, 59, 999)
438+
return { startDate: start, endDate: end }
439+
}
440+
case '7days':
441+
return { startDate: subDays(now, 7), endDate: now }
442+
case '15days':
443+
return { startDate: subDays(now, 15), endDate: now }
444+
case '30days':
445+
return { startDate: subDays(now, 30), endDate: now }
446+
default:
447+
return { startDate: subDays(now, 30), endDate: now }
448+
}
449+
}
450+
451+
function formatFilterLabel(filter: GlobeDateFilter): string {
452+
if (filter.quickFilter !== 'custom') {
453+
return (
454+
GLOBE_QUICK_FILTERS.find((f) => f.value === filter.quickFilter)?.label ??
455+
'Last 30 Days'
456+
)
457+
}
458+
if (filter.dateRange?.from) {
459+
const from = format(filter.dateRange.from, 'MMM d, yyyy')
460+
const to = filter.dateRange.to
461+
? format(filter.dateRange.to, 'MMM d, yyyy')
462+
: from
463+
return `${from} - ${to}`
464+
}
465+
return 'Custom range'
466+
}
467+
382468
// ─── Main page component ─────────────────────────────────────────
383469

384470
export function VisitorGlobePage({ project }: VisitorGlobePageProps) {
@@ -387,21 +473,22 @@ export function VisitorGlobePage({ project }: VisitorGlobePageProps) {
387473
[]
388474
)
389475
const [isHovered, setIsHovered] = useState(false)
476+
const [dateFilter, setDateFilter] = useState<GlobeDateFilter>({
477+
quickFilter: '30days',
478+
dateRange: undefined,
479+
})
390480

391-
// Last 30 days
392-
const dateRange = useMemo(() => {
393-
const now = new Date()
394-
const thirtyDaysAgo = new Date(now)
395-
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
396-
return { startDate: thirtyDaysAgo, endDate: now }
397-
}, [])
481+
const { startDate, endDate } = useMemo(
482+
() => resolveGlobeDateRange(dateFilter),
483+
[dateFilter]
484+
)
398485

399486
const { data: visitorsData } = useQuery({
400487
...getVisitorsOptions({
401488
query: {
402489
project_id: project.id,
403-
start_date: dateRange.startDate.toISOString(),
404-
end_date: dateRange.endDate.toISOString(),
490+
start_date: startDate.toISOString(),
491+
end_date: endDate.toISOString(),
405492
limit: 200,
406493
has_activity_only: true,
407494
},
@@ -473,6 +560,14 @@ export function VisitorGlobePage({ project }: VisitorGlobePageProps) {
473560
[]
474561
)
475562

563+
const handleQuickFilter = useCallback((value: GlobeQuickFilter) => {
564+
setDateFilter({ quickFilter: value, dateRange: undefined })
565+
}, [])
566+
567+
const handleCustomDateRange = useCallback((range: DateRange | undefined) => {
568+
setDateFilter({ quickFilter: 'custom', dateRange: range })
569+
}, [])
570+
476571
return (
477572
<div className="space-y-4">
478573
{/* Header */}
@@ -491,8 +586,8 @@ export function VisitorGlobePage({ project }: VisitorGlobePageProps) {
491586
Visitor Globe
492587
</h2>
493588
<p className="text-sm text-muted-foreground">
494-
{visitorsData?.filtered_count ?? 0} visitors from around the world
495-
in the last 30 days
589+
{visitorsData?.filtered_count ?? 0} visitors &middot;{' '}
590+
{formatFilterLabel(dateFilter)}
496591
</p>
497592
</div>
498593
</div>
@@ -516,6 +611,83 @@ export function VisitorGlobePage({ project }: VisitorGlobePageProps) {
516611
</div>
517612
</div>
518613

614+
{/* Date filters */}
615+
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
616+
{/* Quick filter buttons — desktop */}
617+
<div className="hidden sm:flex gap-1">
618+
{GLOBE_QUICK_FILTERS.filter((f) => f.value !== 'custom').map(
619+
(filter) => (
620+
<Button
621+
key={filter.value}
622+
variant={
623+
dateFilter.quickFilter === filter.value ? 'default' : 'outline'
624+
}
625+
size="sm"
626+
onClick={() => handleQuickFilter(filter.value)}
627+
>
628+
{filter.label}
629+
</Button>
630+
)
631+
)}
632+
</div>
633+
634+
{/* Quick filter dropdown — mobile */}
635+
<div className="sm:hidden">
636+
<Select
637+
value={dateFilter.quickFilter}
638+
onValueChange={(v) => handleQuickFilter(v as GlobeQuickFilter)}
639+
>
640+
<SelectTrigger className="w-[160px]">
641+
<SelectValue />
642+
</SelectTrigger>
643+
<SelectContent>
644+
{GLOBE_QUICK_FILTERS.filter((f) => f.value !== 'custom').map(
645+
(filter) => (
646+
<SelectItem key={filter.value} value={filter.value}>
647+
{filter.label}
648+
</SelectItem>
649+
)
650+
)}
651+
</SelectContent>
652+
</Select>
653+
</div>
654+
655+
{/* Custom date range calendar */}
656+
<Popover>
657+
<PopoverTrigger asChild>
658+
<Button
659+
variant={
660+
dateFilter.quickFilter === 'custom' ? 'default' : 'outline'
661+
}
662+
size="sm"
663+
className={cn(
664+
'min-w-[140px]',
665+
dateFilter.quickFilter !== 'custom' && 'text-muted-foreground'
666+
)}
667+
>
668+
<CalendarIcon className="mr-2 h-4 w-4" />
669+
{dateFilter.quickFilter === 'custom' && dateFilter.dateRange?.from
670+
? dateFilter.dateRange.to
671+
? `${format(dateFilter.dateRange.from, 'LLL dd, y')} - ${format(dateFilter.dateRange.to, 'LLL dd, y')}`
672+
: format(dateFilter.dateRange.from, 'LLL dd, y')
673+
: 'Custom range'}
674+
</Button>
675+
</PopoverTrigger>
676+
<PopoverContent className="w-auto p-0" align="start">
677+
<Calendar
678+
initialFocus
679+
mode="range"
680+
defaultMonth={subDays(new Date(), 30)}
681+
selected={dateFilter.dateRange}
682+
onSelect={handleCustomDateRange}
683+
numberOfMonths={2}
684+
disabled={(date) => date > new Date()}
685+
toDate={new Date()}
686+
/>
687+
</PopoverContent>
688+
</Popover>
689+
</div>
690+
519691
{/* Globe + Sidebar layout */}
520692
<div className="flex flex-col lg:flex-row gap-6">
521693
{/* Globe container — hover pauses rotation */}

0 commit comments

Comments
 (0)