|
| 1 | +'use client'; |
| 2 | + |
| 3 | +import { type DynamicQueryFilter, filterOptions } from '@databuddy/shared'; |
| 4 | +import { ArrowClockwiseIcon, XIcon } from '@phosphor-icons/react'; |
| 5 | +import dayjs from 'dayjs'; |
| 6 | +import { useAtom } from 'jotai'; |
| 7 | +import { useCallback, useMemo } from 'react'; |
| 8 | +import type { DateRange as DayPickerRange } from 'react-day-picker'; |
| 9 | +import { DateRangePicker } from '@/components/date-range-picker'; |
| 10 | +import { Button } from '@/components/ui/button'; |
| 11 | +import { operatorOptions, useFilters } from '@/hooks/use-filters'; |
| 12 | +import { |
| 13 | + dateRangeAtom, |
| 14 | + setDateRangeAndAdjustGranularityAtom, |
| 15 | + timeGranularityAtom, |
| 16 | +} from '@/stores/jotai/filterAtoms'; |
| 17 | +import { AddFilterForm, getOperatorShorthand } from './utils/add-filters'; |
| 18 | + |
| 19 | +interface AnalyticsToolbarProps { |
| 20 | + isRefreshing: boolean; |
| 21 | + onRefresh: () => void; |
| 22 | + selectedFilters: DynamicQueryFilter[]; |
| 23 | + onFiltersChange: (filters: DynamicQueryFilter[]) => void; |
| 24 | +} |
| 25 | + |
| 26 | +export function AnalyticsToolbar({ |
| 27 | + isRefreshing, |
| 28 | + onRefresh, |
| 29 | + selectedFilters, |
| 30 | + onFiltersChange, |
| 31 | +}: AnalyticsToolbarProps) { |
| 32 | + const [currentDateRange] = useAtom(dateRangeAtom); |
| 33 | + const [currentGranularity, setCurrentGranularityAtomState] = |
| 34 | + useAtom(timeGranularityAtom); |
| 35 | + const [, setDateRangeAction] = useAtom(setDateRangeAndAdjustGranularityAtom); |
| 36 | + |
| 37 | + const { addFilter, removeFilter } = useFilters({ |
| 38 | + filters: selectedFilters, |
| 39 | + onFiltersChange, |
| 40 | + }); |
| 41 | + |
| 42 | + const dayPickerSelectedRange: DayPickerRange | undefined = useMemo( |
| 43 | + () => ({ |
| 44 | + from: currentDateRange.startDate, |
| 45 | + to: currentDateRange.endDate, |
| 46 | + }), |
| 47 | + [currentDateRange] |
| 48 | + ); |
| 49 | + |
| 50 | + const quickRanges = useMemo( |
| 51 | + () => [ |
| 52 | + { label: '24h', fullLabel: 'Last 24 hours', hours: 24 }, |
| 53 | + { label: '7d', fullLabel: 'Last 7 days', days: 7 }, |
| 54 | + { label: '30d', fullLabel: 'Last 30 days', days: 30 }, |
| 55 | + { label: '90d', fullLabel: 'Last 90 days', days: 90 }, |
| 56 | + { label: '180d', fullLabel: 'Last 180 days', days: 180 }, |
| 57 | + { label: '365d', fullLabel: 'Last 365 days', days: 365 }, |
| 58 | + ], |
| 59 | + [] |
| 60 | + ); |
| 61 | + |
| 62 | + const handleQuickRangeSelect = useCallback( |
| 63 | + (range: (typeof quickRanges)[0]) => { |
| 64 | + const now = new Date(); |
| 65 | + const start = range.hours |
| 66 | + ? dayjs(now).subtract(range.hours, 'hour').toDate() |
| 67 | + : dayjs(now) |
| 68 | + .subtract(range.days || 7, 'day') |
| 69 | + .toDate(); |
| 70 | + setDateRangeAction({ startDate: start, endDate: now }); |
| 71 | + }, |
| 72 | + [setDateRangeAction] |
| 73 | + ); |
| 74 | + |
| 75 | + return ( |
| 76 | + <> |
| 77 | + <div className="mt-3 flex flex-col gap-3 rounded-lg border bg-muted/30 p-2.5"> |
| 78 | + <div className="flex items-center justify-between gap-3"> |
| 79 | + <div className="flex h-8 overflow-hidden rounded-md border bg-background shadow-sm"> |
| 80 | + <Button |
| 81 | + className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'daily' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`} |
| 82 | + onClick={() => setCurrentGranularityAtomState('daily')} |
| 83 | + size="sm" |
| 84 | + title="View daily aggregated data" |
| 85 | + variant="ghost" |
| 86 | + > |
| 87 | + Daily |
| 88 | + </Button> |
| 89 | + <Button |
| 90 | + className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'hourly' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`} |
| 91 | + onClick={() => setCurrentGranularityAtomState('hourly')} |
| 92 | + size="sm" |
| 93 | + title="View hourly data (best for 24h periods)" |
| 94 | + variant="ghost" |
| 95 | + > |
| 96 | + Hourly |
| 97 | + </Button> |
| 98 | + </div> |
| 99 | + |
| 100 | + <Button |
| 101 | + aria-label="Refresh data" |
| 102 | + className="h-8 w-8" |
| 103 | + disabled={isRefreshing} |
| 104 | + onClick={onRefresh} |
| 105 | + size="icon" |
| 106 | + variant="outline" |
| 107 | + > |
| 108 | + <ArrowClockwiseIcon |
| 109 | + aria-hidden="true" |
| 110 | + className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} |
| 111 | + /> |
| 112 | + </Button> |
| 113 | + </div> |
| 114 | + |
| 115 | + <div className="flex items-center gap-2 overflow-x-auto rounded-md border bg-background p-1 shadow-sm"> |
| 116 | + {quickRanges.map((range) => { |
| 117 | + const now = new Date(); |
| 118 | + const start = range.hours |
| 119 | + ? dayjs(now).subtract(range.hours, 'hour').toDate() |
| 120 | + : dayjs(now) |
| 121 | + .subtract(range.days || 7, 'day') |
| 122 | + .toDate(); |
| 123 | + const dayPickerCurrentRange = dayPickerSelectedRange; |
| 124 | + const isActive = |
| 125 | + dayPickerCurrentRange?.from && |
| 126 | + dayPickerCurrentRange?.to && |
| 127 | + dayjs(dayPickerCurrentRange.from).format('YYYY-MM-DD') === |
| 128 | + dayjs(start).format('YYYY-MM-DD') && |
| 129 | + dayjs(dayPickerCurrentRange.to).format('YYYY-MM-DD') === |
| 130 | + dayjs(now).format('YYYY-MM-DD'); |
| 131 | + |
| 132 | + return ( |
| 133 | + <Button |
| 134 | + className={`h-6 cursor-pointer touch-manipulation whitespace-nowrap px-2 text-xs sm:px-2.5 ${isActive ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} |
| 135 | + key={range.label} |
| 136 | + onClick={() => handleQuickRangeSelect(range)} |
| 137 | + size="sm" |
| 138 | + title={range.fullLabel} |
| 139 | + variant={isActive ? 'default' : 'ghost'} |
| 140 | + > |
| 141 | + <span className="sm:hidden">{range.label}</span> |
| 142 | + <span className="hidden sm:inline">{range.fullLabel}</span> |
| 143 | + </Button> |
| 144 | + ); |
| 145 | + })} |
| 146 | + |
| 147 | + <div className="ml-1 border-border/50 border-l pl-2 sm:pl-3"> |
| 148 | + <DateRangePicker |
| 149 | + className="w-auto" |
| 150 | + maxDate={new Date()} |
| 151 | + minDate={new Date(2020, 0, 1)} |
| 152 | + onChange={(range) => { |
| 153 | + if (range?.from && range?.to) { |
| 154 | + setDateRangeAction({ |
| 155 | + startDate: range.from, |
| 156 | + endDate: range.to, |
| 157 | + }); |
| 158 | + } |
| 159 | + }} |
| 160 | + value={dayPickerSelectedRange} |
| 161 | + /> |
| 162 | + </div> |
| 163 | + |
| 164 | + <div className="ml-2 flex items-center"> |
| 165 | + <AddFilterForm addFilter={addFilter} /> |
| 166 | + </div> |
| 167 | + </div> |
| 168 | + </div> |
| 169 | + |
| 170 | + {selectedFilters.length > 0 && ( |
| 171 | + <div className="mt-3 rounded-lg border bg-muted/30 p-2.5"> |
| 172 | + <div className="flex items-center justify-between gap-3"> |
| 173 | + <div className="flex items-center gap-2 overflow-x-auto"> |
| 174 | + <div className="font-semibold text-sm">Filters</div> |
| 175 | + <div className="flex flex-wrap items-center gap-2"> |
| 176 | + {selectedFilters.map((filter, index) => { |
| 177 | + const fieldLabel = filterOptions.find( |
| 178 | + (o) => o.value === filter.field |
| 179 | + )?.label; |
| 180 | + const operatorLabel = operatorOptions.find( |
| 181 | + (o) => getOperatorShorthand(o.value) === filter.operator |
| 182 | + )?.label; |
| 183 | + const valueLabel = Array.isArray(filter.value) |
| 184 | + ? filter.value.join(', ') |
| 185 | + : filter.value; |
| 186 | + |
| 187 | + return ( |
| 188 | + <div |
| 189 | + className="flex items-center gap-0 rounded border bg-background py-1 pr-2 pl-3 shadow-sm" |
| 190 | + key={`filter-${index}-${filter.field}-${filter.operator}`} |
| 191 | + > |
| 192 | + <div className="flex items-center gap-1"> |
| 193 | + <span className="font-medium text-foreground text-sm"> |
| 194 | + {fieldLabel} |
| 195 | + </span> |
| 196 | + <span className="text-muted-foreground/70 text-sm"> |
| 197 | + {operatorLabel} |
| 198 | + </span> |
| 199 | + <span className="font-medium text-foreground text-sm"> |
| 200 | + {valueLabel} |
| 201 | + </span> |
| 202 | + </div> |
| 203 | + <button |
| 204 | + aria-label={`Remove filter ${fieldLabel} ${operatorLabel} ${valueLabel}`} |
| 205 | + className="flex h-6 w-6 items-center justify-center rounded hover:bg-muted/50" |
| 206 | + onClick={() => removeFilter(index)} |
| 207 | + type="button" |
| 208 | + > |
| 209 | + <XIcon aria-hidden="true" className="h-3 w-3" /> |
| 210 | + </button> |
| 211 | + </div> |
| 212 | + ); |
| 213 | + })} |
| 214 | + </div> |
| 215 | + </div> |
| 216 | + |
| 217 | + <Button onClick={() => onFiltersChange([])} variant="outline"> |
| 218 | + Clear all filters |
| 219 | + </Button> |
| 220 | + </div> |
| 221 | + </div> |
| 222 | + )} |
| 223 | + </> |
| 224 | + ); |
| 225 | +} |
0 commit comments