@gram-ai/elements 1.26.1 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +83 -15
  2. package/dist/components/Chat/stories/ConnectionConfiguration.stories.d.ts +1 -1
  3. package/dist/components/ui/calendar.d.ts +25 -0
  4. package/dist/components/ui/time-range-picker.d.ts +46 -0
  5. package/dist/components/ui/time-range-picker.stories.d.ts +37 -0
  6. package/dist/core-Cqad6-xW.js +36 -0
  7. package/dist/core-Cqad6-xW.js.map +1 -0
  8. package/dist/core-DBxmxwCi.cjs +2 -0
  9. package/dist/core-DBxmxwCi.cjs.map +1 -0
  10. package/dist/elements.cjs +1 -1
  11. package/dist/elements.css +1 -1
  12. package/dist/elements.js +18 -14
  13. package/dist/hooks/useModel.d.ts +2 -0
  14. package/dist/index-CP-wWZCV.cjs +172 -0
  15. package/dist/index-CP-wWZCV.cjs.map +1 -0
  16. package/dist/{index-DfqYP0CD.js → index-oO5BAmPI.js} +12578 -11964
  17. package/dist/index-oO5BAmPI.js.map +1 -0
  18. package/dist/index.d.ts +5 -1
  19. package/dist/lib/auth.d.ts +12 -4
  20. package/dist/lib/models.d.ts +1 -1
  21. package/dist/{profiler-ZLr2-8s7.cjs → profiler-CEpc7O5Q.cjs} +2 -2
  22. package/dist/{profiler-ZLr2-8s7.cjs.map → profiler-CEpc7O5Q.cjs.map} +1 -1
  23. package/dist/{profiler-WoFj2UH8.js → profiler-ECh1zoXF.js} +2 -2
  24. package/dist/{profiler-WoFj2UH8.js.map → profiler-ECh1zoXF.js.map} +1 -1
  25. package/dist/server/bun.cjs +2 -0
  26. package/dist/server/bun.cjs.map +1 -0
  27. package/dist/server/bun.d.ts +8 -0
  28. package/dist/server/bun.js +26 -0
  29. package/dist/server/bun.js.map +1 -0
  30. package/dist/server/core.d.ts +37 -0
  31. package/dist/server/express.cjs +2 -0
  32. package/dist/server/express.cjs.map +1 -0
  33. package/dist/server/express.d.ts +9 -0
  34. package/dist/server/express.js +21 -0
  35. package/dist/server/express.js.map +1 -0
  36. package/dist/server/fastify.cjs +2 -0
  37. package/dist/server/fastify.cjs.map +1 -0
  38. package/dist/server/fastify.d.ts +9 -0
  39. package/dist/server/fastify.js +19 -0
  40. package/dist/server/fastify.js.map +1 -0
  41. package/dist/server/hono.cjs +2 -0
  42. package/dist/server/hono.cjs.map +1 -0
  43. package/dist/server/hono.d.ts +9 -0
  44. package/dist/server/hono.js +20 -0
  45. package/dist/server/hono.js.map +1 -0
  46. package/dist/server/nextjs.cjs +2 -0
  47. package/dist/server/nextjs.cjs.map +1 -0
  48. package/dist/server/nextjs.d.ts +8 -0
  49. package/dist/server/nextjs.js +26 -0
  50. package/dist/server/nextjs.js.map +1 -0
  51. package/dist/server/tanstack-start.cjs +2 -0
  52. package/dist/server/tanstack-start.cjs.map +1 -0
  53. package/dist/server/tanstack-start.d.ts +25 -0
  54. package/dist/server/tanstack-start.js +39 -0
  55. package/dist/server/tanstack-start.js.map +1 -0
  56. package/dist/server.cjs +1 -1
  57. package/dist/server.cjs.map +1 -1
  58. package/dist/server.d.ts +10 -16
  59. package/dist/server.js +22 -29
  60. package/dist/server.js.map +1 -1
  61. package/dist/{startRecording-DzQo16WK.js → startRecording-CmZjjJoz.js} +2 -2
  62. package/dist/{startRecording-DzQo16WK.js.map → startRecording-CmZjjJoz.js.map} +1 -1
  63. package/dist/{startRecording-BGnWDInp.cjs → startRecording-qDCAu4Q0.cjs} +2 -2
  64. package/dist/{startRecording-BGnWDInp.cjs.map → startRecording-qDCAu4Q0.cjs.map} +1 -1
  65. package/dist/types/index.d.ts +22 -10
  66. package/package.json +63 -3
  67. package/src/components/Chat/stories/ConnectionConfiguration.stories.tsx +6 -8
  68. package/src/components/assistant-ui/thread.tsx +8 -14
  69. package/src/components/ui/calendar.tsx +262 -0
  70. package/src/components/ui/time-range-picker.stories.tsx +249 -0
  71. package/src/components/ui/time-range-picker.tsx +675 -0
  72. package/src/hooks/useAuth.ts +59 -7
  73. package/src/hooks/useFollowOnSuggestions.ts +7 -14
  74. package/src/hooks/useModel.ts +30 -0
  75. package/src/index.ts +17 -0
  76. package/src/lib/api.test.ts +4 -4
  77. package/src/lib/auth.ts +34 -4
  78. package/src/lib/models.ts +1 -0
  79. package/src/server/bun.ts +63 -0
  80. package/src/server/core.ts +84 -0
  81. package/src/server/express.ts +60 -0
  82. package/src/server/fastify.ts +61 -0
  83. package/src/server/hono.ts +55 -0
  84. package/src/server/nextjs.ts +58 -0
  85. package/src/server/tanstack-start.ts +110 -0
  86. package/src/server.ts +37 -49
  87. package/src/types/index.ts +25 -9
  88. package/dist/index-DdrZQXwQ.cjs +0 -147
  89. package/dist/index-DdrZQXwQ.cjs.map +0 -1
  90. package/dist/index-DfqYP0CD.js.map +0 -1
@@ -0,0 +1,675 @@
1
+ /* eslint-disable react-refresh/only-export-components */
2
+ import * as React from 'react'
3
+ import { CalendarIcon, ChevronDown, Zap } from 'lucide-react'
4
+ import { generateObject } from 'ai'
5
+ import { createOpenRouter } from '@openrouter/ai-sdk-provider'
6
+ import { z } from 'zod'
7
+
8
+ import { cn } from '@/lib/utils'
9
+ import { Popover, PopoverContent, PopoverTrigger } from './popover'
10
+ import { Calendar } from './calendar'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export interface TimeRange {
17
+ from: Date
18
+ to: Date
19
+ }
20
+
21
+ export type DateRangePreset =
22
+ | '15m'
23
+ | '1h'
24
+ | '4h'
25
+ | '1d'
26
+ | '2d'
27
+ | '3d'
28
+ | '7d'
29
+ | '15d'
30
+ | '30d'
31
+ | '90d'
32
+
33
+ export interface TimeRangePreset {
34
+ label: string
35
+ shortLabel: string
36
+ value: DateRangePreset
37
+ getRange: () => TimeRange
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Date Utilities (no external dependencies)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function formatDate(date: Date, pattern: 'short' | 'medium' = 'short'): string {
45
+ if (pattern === 'short') {
46
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
47
+ }
48
+ return date.toLocaleDateString('en-US', {
49
+ month: 'short',
50
+ day: 'numeric',
51
+ year: 'numeric',
52
+ })
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Presets Configuration
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const PRESETS: TimeRangePreset[] = [
60
+ {
61
+ label: 'Past 15 Minutes',
62
+ shortLabel: '15m',
63
+ value: '15m',
64
+ getRange: () => ({
65
+ from: new Date(Date.now() - 15 * 60 * 1000),
66
+ to: new Date(),
67
+ }),
68
+ },
69
+ {
70
+ label: 'Past 1 Hour',
71
+ shortLabel: '1h',
72
+ value: '1h',
73
+ getRange: () => ({
74
+ from: new Date(Date.now() - 60 * 60 * 1000),
75
+ to: new Date(),
76
+ }),
77
+ },
78
+ {
79
+ label: 'Past 4 Hours',
80
+ shortLabel: '4h',
81
+ value: '4h',
82
+ getRange: () => ({
83
+ from: new Date(Date.now() - 4 * 60 * 60 * 1000),
84
+ to: new Date(),
85
+ }),
86
+ },
87
+ {
88
+ label: 'Past 1 Day',
89
+ shortLabel: '1d',
90
+ value: '1d',
91
+ getRange: () => ({
92
+ from: new Date(Date.now() - 24 * 60 * 60 * 1000),
93
+ to: new Date(),
94
+ }),
95
+ },
96
+ {
97
+ label: 'Past 2 Days',
98
+ shortLabel: '2d',
99
+ value: '2d',
100
+ getRange: () => ({
101
+ from: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
102
+ to: new Date(),
103
+ }),
104
+ },
105
+ {
106
+ label: 'Past 3 Days',
107
+ shortLabel: '3d',
108
+ value: '3d',
109
+ getRange: () => ({
110
+ from: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
111
+ to: new Date(),
112
+ }),
113
+ },
114
+ {
115
+ label: 'Past 7 Days',
116
+ shortLabel: '1w',
117
+ value: '7d',
118
+ getRange: () => ({
119
+ from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
120
+ to: new Date(),
121
+ }),
122
+ },
123
+ {
124
+ label: 'Past 15 Days',
125
+ shortLabel: '15d',
126
+ value: '15d',
127
+ getRange: () => ({
128
+ from: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
129
+ to: new Date(),
130
+ }),
131
+ },
132
+ {
133
+ label: 'Past 1 Month',
134
+ shortLabel: '1mo',
135
+ value: '30d',
136
+ getRange: () => ({
137
+ from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
138
+ to: new Date(),
139
+ }),
140
+ },
141
+ {
142
+ label: 'Past 3 Months',
143
+ shortLabel: '3mo',
144
+ value: '90d',
145
+ getRange: () => ({
146
+ from: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
147
+ to: new Date(),
148
+ }),
149
+ },
150
+ ]
151
+
152
+ // Badge width class - shared between trigger and dropdown for alignment
153
+ const BADGE_WIDTH = 'min-w-10'
154
+
155
+ export function getPresetRange(preset: DateRangePreset): TimeRange {
156
+ const p = PRESETS.find((p) => p.value === preset)
157
+ return p ? p.getRange() : PRESETS[5].getRange() // Default to 3d
158
+ }
159
+
160
+ function getPresetByValue(value: DateRangePreset): TimeRangePreset | undefined {
161
+ return PRESETS.find((p) => p.value === value)
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // AI Time Range Parser
166
+ // ---------------------------------------------------------------------------
167
+
168
+ type ParseResult =
169
+ | { type: 'preset'; preset: DateRangePreset }
170
+ | { type: 'custom'; range: TimeRange; label?: string }
171
+ | null
172
+
173
+ const timeRangeSchema = z.object({
174
+ from: z.string().describe('ISO8601 start date/time'),
175
+ to: z.string().describe('ISO8601 end date/time'),
176
+ label: z.string().describe('Short semantic label for the range'),
177
+ })
178
+
179
+ const TIME_RANGE_MODEL = 'openai/gpt-4o-mini'
180
+
181
+ async function parseWithAI(
182
+ input: string,
183
+ apiUrl: string,
184
+ projectSlug?: string
185
+ ): Promise<ParseResult> {
186
+ try {
187
+ const now = new Date()
188
+
189
+ // Create OpenRouter provider without X-Gram-Source header (so usage is billed)
190
+ const headers: Record<string, string> = {}
191
+ if (projectSlug) {
192
+ headers['Gram-Project'] = projectSlug
193
+ }
194
+
195
+ const openRouter = createOpenRouter({
196
+ baseURL: apiUrl,
197
+ apiKey: 'unused',
198
+ headers,
199
+ fetch: (url, init) =>
200
+ fetch(url, {
201
+ ...init,
202
+ credentials: 'include',
203
+ }),
204
+ })
205
+
206
+ const model = openRouter.chat(TIME_RANGE_MODEL)
207
+
208
+ const result = await generateObject({
209
+ model,
210
+ schema: timeRangeSchema,
211
+ prompt: `You are a time range parser for an analytics dashboard. Parse natural language into a PAST time range.
212
+ Current time: ${now.toISOString()}
213
+
214
+ KEY RULES:
215
+ - "X days ago" = THE WHOLE DAY (from: start 00:00, to: end 23:59:59)
216
+ - "X months ago" = THE WHOLE MONTH (from: 1st 00:00, to: last day 23:59:59)
217
+ - "X years ago" = THE WHOLE YEAR (from: Jan 1, to: Dec 31)
218
+ - "past X days" = RANGE from X days ago to now
219
+ - "last wednesday" etc = that specific day (whole day)
220
+
221
+ LABEL RULES - use semantic labels:
222
+ - Duration presets: "15m", "1h", "4h", "1d", "2d", "3d", "7d", "15d", "30d"
223
+ - Single day: use 3-letter day name "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"
224
+ - Whole month: use 3-letter month name "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
225
+ - Whole year: use the year "2024", "2025"
226
+ - Date range: use "M/D-M/D" format like "1/5-1/10" or "12/1-12/15"
227
+
228
+ Examples:
229
+ - "4 days ago" -> label: "Mon" (or whatever day it was)
230
+ - "2 months ago" -> label: "Dec" (or whatever month)
231
+ - "1 year ago" -> label: "2025" (or whatever year)
232
+ - "past 3 days" -> label: "3d"
233
+ - "last wednesday" -> label: "Wed"
234
+ - "jan 5 to jan 10" -> label: "1/5-1/10"
235
+
236
+ User input: ${input}`,
237
+ })
238
+
239
+ const parsed = result.object
240
+ const from = new Date(parsed.from)
241
+ const to = new Date(parsed.to)
242
+
243
+ if (isNaN(from.getTime()) || isNaN(to.getTime())) {
244
+ return null
245
+ }
246
+
247
+ // Normalize labels like "1w" -> "7d", "2w" -> "14d"
248
+ let normalizedLabel = parsed.label
249
+ if (normalizedLabel === '1w') normalizedLabel = '7d'
250
+ if (normalizedLabel === '2w') normalizedLabel = '14d'
251
+ if (normalizedLabel === '1mo') normalizedLabel = '30d'
252
+ if (normalizedLabel === '3mo') normalizedLabel = '90d'
253
+
254
+ const matchedPreset = PRESETS.find((p) => p.value === normalizedLabel)
255
+ if (matchedPreset) {
256
+ return { type: 'preset', preset: matchedPreset.value }
257
+ }
258
+
259
+ // Use the semantic label from AI (e.g., "Mon", "Jan", "2024", "1/5-1/10")
260
+ return { type: 'custom', range: { from, to }, label: parsed.label }
261
+ } catch {
262
+ return null
263
+ }
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // TimeRangePicker Component (Datadog Style)
268
+ // ---------------------------------------------------------------------------
269
+
270
+ export interface TimeRangePickerProps {
271
+ /** Current preset value */
272
+ preset?: DateRangePreset | null
273
+ /** Current custom range */
274
+ customRange?: TimeRange | null
275
+ /** Called when a preset is selected */
276
+ onPresetChange?: (preset: DateRangePreset) => void
277
+ /** Called when a custom range is selected */
278
+ onCustomRangeChange?: (from: Date, to: Date, label?: string) => void
279
+ /** Called to clear custom range */
280
+ onClearCustomRange?: () => void
281
+ /** Initial label for custom range (from URL params) */
282
+ customRangeLabel?: string | null
283
+ /** Show LIVE mode option */
284
+ showLive?: boolean
285
+ /** Is LIVE mode active */
286
+ isLive?: boolean
287
+ /** Called when LIVE mode changes */
288
+ onLiveChange?: (isLive: boolean) => void
289
+ /** Disabled state */
290
+ disabled?: boolean
291
+ /** Timezone display (e.g., "UTC-08:00") */
292
+ timezone?: string
293
+ /** API URL for AI parsing (defaults to window.location.origin) */
294
+ apiUrl?: string
295
+ /** Project slug for API authentication */
296
+ projectSlug?: string
297
+ }
298
+
299
+ function TimeRangePicker({
300
+ preset,
301
+ customRange,
302
+ onPresetChange,
303
+ onCustomRangeChange,
304
+ onClearCustomRange,
305
+ customRangeLabel: initialCustomLabel,
306
+ showLive = false,
307
+ isLive = false,
308
+ onLiveChange,
309
+ disabled = false,
310
+ timezone,
311
+ apiUrl,
312
+ projectSlug,
313
+ }: TimeRangePickerProps) {
314
+ const [isOpen, setIsOpen] = React.useState(false)
315
+ const [showCalendar, setShowCalendar] = React.useState(false)
316
+ const [inputValue, setInputValue] = React.useState('')
317
+ const [isEditing, setIsEditing] = React.useState(false)
318
+ const [isParsing, setIsParsing] = React.useState(false)
319
+ const [customLabel, setCustomLabel] = React.useState<string | null>(
320
+ initialCustomLabel || null
321
+ )
322
+ const inputRef = React.useRef<HTMLInputElement>(null)
323
+
324
+ // Sync custom label from props (e.g., when URL changes)
325
+ React.useEffect(() => {
326
+ if (initialCustomLabel !== undefined) {
327
+ setCustomLabel(initialCustomLabel || null)
328
+ }
329
+ }, [initialCustomLabel])
330
+
331
+ const effectiveApiUrl =
332
+ apiUrl || (typeof window !== 'undefined' ? window.location.origin : '')
333
+
334
+ const handlePresetClick = (p: TimeRangePreset) => {
335
+ onPresetChange?.(p.value)
336
+ setCustomLabel(null)
337
+ setIsOpen(false)
338
+ setInputValue('')
339
+ }
340
+
341
+ const handleLiveClick = () => {
342
+ onLiveChange?.(!isLive)
343
+ if (!isLive) {
344
+ // When enabling LIVE, also select a default short preset
345
+ onPresetChange?.('15m')
346
+ }
347
+ setIsOpen(false)
348
+ }
349
+
350
+ const handleCalendarSelect = (range: { start: Date; end: Date | null }) => {
351
+ if (range.start && range.end) {
352
+ onCustomRangeChange?.(range.start, range.end)
353
+ setCustomLabel(null) // Calendar selections don't have AI labels
354
+ setIsOpen(false)
355
+ setShowCalendar(false)
356
+ setInputValue('')
357
+ }
358
+ }
359
+
360
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
361
+ setInputValue(e.target.value)
362
+ }
363
+
364
+ const applyParseResult = (parsed: ParseResult) => {
365
+ if (parsed) {
366
+ if (parsed.type === 'preset') {
367
+ onPresetChange?.(parsed.preset)
368
+ setCustomLabel(null)
369
+ } else {
370
+ const label = parsed.label || undefined
371
+ onCustomRangeChange?.(parsed.range.from, parsed.range.to, label)
372
+ setCustomLabel(label || null)
373
+ }
374
+ setInputValue('')
375
+ setIsOpen(false)
376
+ setIsEditing(false)
377
+ return true
378
+ }
379
+ return false
380
+ }
381
+
382
+ const handleInputKeyDown = async (
383
+ e: React.KeyboardEvent<HTMLInputElement>
384
+ ) => {
385
+ if (e.key === 'Enter' && inputValue.trim() && !isParsing) {
386
+ // Use AI to parse natural language input
387
+ setIsParsing(true)
388
+ try {
389
+ const aiParsed = await parseWithAI(
390
+ inputValue,
391
+ effectiveApiUrl,
392
+ projectSlug
393
+ )
394
+ applyParseResult(aiParsed)
395
+ } finally {
396
+ setIsParsing(false)
397
+ }
398
+ } else if (e.key === 'Escape') {
399
+ setInputValue('')
400
+ setIsEditing(false)
401
+ setIsOpen(false)
402
+ } else if (e.key === 'Backspace' && inputValue === '' && customRange) {
403
+ // Clear custom range when backspacing on empty input
404
+ e.preventDefault()
405
+ onClearCustomRange?.()
406
+ }
407
+ }
408
+
409
+ const handleInputClick = (e: React.MouseEvent) => {
410
+ // Prevent the popover trigger from toggling closed
411
+ e.stopPropagation()
412
+ setIsEditing(true)
413
+ setIsOpen(true)
414
+ }
415
+
416
+ const handleInputFocus = () => {
417
+ setIsEditing(true)
418
+ // Don't set isOpen here - let the click handler or popover manage it
419
+ }
420
+
421
+ const handleInputBlur = () => {
422
+ // Delay to allow click events on dropdown items
423
+ setTimeout(() => {
424
+ if (!inputValue) {
425
+ setIsEditing(false)
426
+ }
427
+ }, 150)
428
+ }
429
+
430
+ // Determine current range for display
431
+ const currentRange = customRange ?? (preset ? getPresetRange(preset) : null)
432
+
433
+ // Get short label for trigger badge
434
+ const getShortLabel = () => {
435
+ if (customRange) return customLabel || 'Custom'
436
+ if (preset) {
437
+ const presetObj = getPresetByValue(preset)
438
+ return presetObj?.shortLabel ?? preset
439
+ }
440
+ return '7d'
441
+ }
442
+
443
+ // Get label text (preset label or custom range description)
444
+ const getLabelText = () => {
445
+ if (customRange) {
446
+ return `${formatDate(customRange.from)} – ${formatDate(customRange.to)}`
447
+ }
448
+ if (preset) {
449
+ const presetObj = getPresetByValue(preset)
450
+ return presetObj?.label ?? 'Select time range'
451
+ }
452
+ return 'Select time range'
453
+ }
454
+
455
+ const handleOpenChange = (open: boolean) => {
456
+ // If closing while editing, keep it open unless explicitly closed via selection
457
+ if (!open && isEditing) {
458
+ return
459
+ }
460
+ setIsOpen(open)
461
+ if (open && inputRef.current) {
462
+ // Focus input when opening
463
+ setTimeout(() => inputRef.current?.focus(), 0)
464
+ }
465
+ }
466
+
467
+ return (
468
+ <Popover open={isOpen} onOpenChange={handleOpenChange}>
469
+ <PopoverTrigger asChild disabled={disabled}>
470
+ <div
471
+ className={cn(
472
+ 'bg-background relative inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all outline-none',
473
+ 'border-border hover:border-border/80',
474
+ disabled && 'cursor-not-allowed opacity-50',
475
+ timezone && 'pt-4'
476
+ )}
477
+ >
478
+ {/* Floating timezone legend */}
479
+ {timezone && (
480
+ <span className="bg-background text-muted-foreground absolute -top-2 left-3 px-1 text-xs">
481
+ {timezone}
482
+ </span>
483
+ )}
484
+
485
+ {/* Short badge */}
486
+ <span
487
+ className={cn(
488
+ 'inline-flex h-6 items-center justify-center rounded px-2 py-1 text-xs font-semibold',
489
+ BADGE_WIDTH,
490
+ isLive
491
+ ? 'bg-green-500 text-white'
492
+ : 'bg-muted text-muted-foreground'
493
+ )}
494
+ >
495
+ {isParsing ? (
496
+ <div className="h-3 w-3 animate-spin rounded-full border-2 border-current/30 border-t-current" />
497
+ ) : (
498
+ getShortLabel()
499
+ )}
500
+ </span>
501
+
502
+ {/* Input field for natural language or display label */}
503
+ <input
504
+ ref={inputRef}
505
+ type="text"
506
+ value={isEditing ? inputValue : getLabelText()}
507
+ onChange={handleInputChange}
508
+ onClick={handleInputClick}
509
+ onFocus={handleInputFocus}
510
+ onBlur={handleInputBlur}
511
+ onKeyDown={handleInputKeyDown}
512
+ placeholder="e.g., 3 days ago, last week..."
513
+ disabled={disabled}
514
+ className={cn(
515
+ 'min-w-[140px] flex-1 bg-transparent outline-none',
516
+ 'placeholder:text-muted-foreground/60',
517
+ !isEditing && 'cursor-pointer',
518
+ disabled && 'cursor-not-allowed'
519
+ )}
520
+ />
521
+
522
+ {/* Dropdown chevron */}
523
+ <ChevronDown className="text-muted-foreground h-4 w-4 shrink-0" />
524
+ </div>
525
+ </PopoverTrigger>
526
+
527
+ <PopoverContent
528
+ className="w-64 p-0"
529
+ align="start"
530
+ onOpenAutoFocus={(e) => {
531
+ // Prevent popover from stealing focus from the input
532
+ e.preventDefault()
533
+ inputRef.current?.focus()
534
+ }}
535
+ >
536
+ <div className="flex flex-col">
537
+ {/* Calendar view */}
538
+ {showCalendar ? (
539
+ <>
540
+ <div className="border-border/50 flex items-center justify-between border-b px-3 py-2">
541
+ <span className="text-muted-foreground text-xs font-medium">
542
+ Select date range
543
+ </span>
544
+ <button
545
+ type="button"
546
+ onClick={() => setShowCalendar(false)}
547
+ className="text-primary text-xs hover:underline"
548
+ >
549
+ Back
550
+ </button>
551
+ </div>
552
+ <Calendar
553
+ selected={{
554
+ start: currentRange?.from ?? null,
555
+ end: currentRange?.to ?? null,
556
+ }}
557
+ onSelect={handleCalendarSelect}
558
+ maxDate={new Date()}
559
+ />
560
+ {customRange && onClearCustomRange && (
561
+ <div className="border-border/50 border-t p-2">
562
+ <button
563
+ type="button"
564
+ onClick={() => {
565
+ onClearCustomRange()
566
+ setShowCalendar(false)
567
+ }}
568
+ className="text-muted-foreground hover:text-foreground w-full text-xs transition-colors"
569
+ >
570
+ Clear custom range
571
+ </button>
572
+ </div>
573
+ )}
574
+ </>
575
+ ) : (
576
+ /* Presets list */
577
+ <div className="py-1">
578
+ {/* LIVE option */}
579
+ {showLive && (
580
+ <button
581
+ type="button"
582
+ onClick={handleLiveClick}
583
+ className={cn(
584
+ 'flex w-full items-center gap-3 px-3 py-2 text-sm transition-colors',
585
+ 'hover:bg-muted',
586
+ isLive && 'bg-blue-500/10'
587
+ )}
588
+ >
589
+ <span
590
+ className={cn(
591
+ 'inline-flex items-center justify-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold',
592
+ BADGE_WIDTH,
593
+ isLive
594
+ ? 'bg-green-500 text-white'
595
+ : 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400'
596
+ )}
597
+ >
598
+ <Zap className="h-3 w-3" />
599
+ LIVE
600
+ </span>
601
+ <span className="text-foreground/80">15 Minutes</span>
602
+ </button>
603
+ )}
604
+
605
+ {/* Preset options */}
606
+ {PRESETS.map((p) => {
607
+ const isSelected = preset === p.value && !customRange && !isLive
608
+ return (
609
+ <button
610
+ key={p.value}
611
+ type="button"
612
+ onClick={() => handlePresetClick(p)}
613
+ className={cn(
614
+ 'flex w-full items-center gap-3 px-3 py-2 text-sm transition-colors',
615
+ isSelected ? 'bg-blue-500 text-white' : 'hover:bg-muted'
616
+ )}
617
+ >
618
+ <span
619
+ className={cn(
620
+ 'inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-semibold',
621
+ BADGE_WIDTH,
622
+ isSelected
623
+ ? 'bg-white/20 text-white'
624
+ : 'bg-muted text-muted-foreground'
625
+ )}
626
+ >
627
+ {p.shortLabel}
628
+ </span>
629
+ <span
630
+ className={
631
+ isSelected ? 'text-white' : 'text-foreground/80'
632
+ }
633
+ >
634
+ {p.label}
635
+ </span>
636
+ </button>
637
+ )
638
+ })}
639
+
640
+ {/* Select from calendar */}
641
+ <button
642
+ type="button"
643
+ onClick={() => setShowCalendar(true)}
644
+ className={cn(
645
+ 'flex w-full items-center gap-3 px-3 py-2 text-sm transition-colors',
646
+ customRange ? 'bg-blue-500 text-white' : 'hover:bg-muted'
647
+ )}
648
+ >
649
+ <span
650
+ className={cn(
651
+ 'inline-flex items-center justify-center rounded px-1.5 py-0.5',
652
+ BADGE_WIDTH,
653
+ customRange
654
+ ? 'bg-white/20 text-white'
655
+ : 'bg-muted text-muted-foreground'
656
+ )}
657
+ >
658
+ <CalendarIcon className="h-4 w-4" />
659
+ </span>
660
+ <span
661
+ className={customRange ? 'text-white' : 'text-foreground/80'}
662
+ >
663
+ Select from calendar...
664
+ </span>
665
+ </button>
666
+ </div>
667
+ )}
668
+ </div>
669
+ </PopoverContent>
670
+ </Popover>
671
+ )
672
+ }
673
+ TimeRangePicker.displayName = 'TimeRangePicker'
674
+
675
+ export { TimeRangePicker }