@@ -8,11 +8,19 @@ import type { ProjectResponse, VisitorInfo } from '@/api/client/types.gen'
88import { Avatar , AvatarFallback } from '@/components/ui/avatar'
99import { Badge } from '@/components/ui/badge'
1010import { Button } from '@/components/ui/button'
11+ import { Calendar } from '@/components/ui/calendar'
1112import {
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'
1624import {
1725 Users ,
1826 ArrowLeft ,
@@ -21,8 +29,12 @@ import {
2129 Monitor ,
2230 Clock ,
2331 FileText ,
32+ Calendar as CalendarIcon ,
2433} from 'lucide-react'
2534import { 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'
2638import { EarthGlobe , type ProjectedMarker } from './EarthGlobe'
2739
2840interface 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
384470export 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 · { ' ' }
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