@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.
- package/README.md +83 -15
- package/dist/components/Chat/stories/ConnectionConfiguration.stories.d.ts +1 -1
- package/dist/components/ui/calendar.d.ts +25 -0
- package/dist/components/ui/time-range-picker.d.ts +46 -0
- package/dist/components/ui/time-range-picker.stories.d.ts +37 -0
- package/dist/core-Cqad6-xW.js +36 -0
- package/dist/core-Cqad6-xW.js.map +1 -0
- package/dist/core-DBxmxwCi.cjs +2 -0
- package/dist/core-DBxmxwCi.cjs.map +1 -0
- package/dist/elements.cjs +1 -1
- package/dist/elements.css +1 -1
- package/dist/elements.js +18 -14
- package/dist/hooks/useModel.d.ts +2 -0
- package/dist/index-CP-wWZCV.cjs +172 -0
- package/dist/index-CP-wWZCV.cjs.map +1 -0
- package/dist/{index-DfqYP0CD.js → index-oO5BAmPI.js} +12578 -11964
- package/dist/index-oO5BAmPI.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/lib/auth.d.ts +12 -4
- package/dist/lib/models.d.ts +1 -1
- package/dist/{profiler-ZLr2-8s7.cjs → profiler-CEpc7O5Q.cjs} +2 -2
- package/dist/{profiler-ZLr2-8s7.cjs.map → profiler-CEpc7O5Q.cjs.map} +1 -1
- package/dist/{profiler-WoFj2UH8.js → profiler-ECh1zoXF.js} +2 -2
- package/dist/{profiler-WoFj2UH8.js.map → profiler-ECh1zoXF.js.map} +1 -1
- package/dist/server/bun.cjs +2 -0
- package/dist/server/bun.cjs.map +1 -0
- package/dist/server/bun.d.ts +8 -0
- package/dist/server/bun.js +26 -0
- package/dist/server/bun.js.map +1 -0
- package/dist/server/core.d.ts +37 -0
- package/dist/server/express.cjs +2 -0
- package/dist/server/express.cjs.map +1 -0
- package/dist/server/express.d.ts +9 -0
- package/dist/server/express.js +21 -0
- package/dist/server/express.js.map +1 -0
- package/dist/server/fastify.cjs +2 -0
- package/dist/server/fastify.cjs.map +1 -0
- package/dist/server/fastify.d.ts +9 -0
- package/dist/server/fastify.js +19 -0
- package/dist/server/fastify.js.map +1 -0
- package/dist/server/hono.cjs +2 -0
- package/dist/server/hono.cjs.map +1 -0
- package/dist/server/hono.d.ts +9 -0
- package/dist/server/hono.js +20 -0
- package/dist/server/hono.js.map +1 -0
- package/dist/server/nextjs.cjs +2 -0
- package/dist/server/nextjs.cjs.map +1 -0
- package/dist/server/nextjs.d.ts +8 -0
- package/dist/server/nextjs.js +26 -0
- package/dist/server/nextjs.js.map +1 -0
- package/dist/server/tanstack-start.cjs +2 -0
- package/dist/server/tanstack-start.cjs.map +1 -0
- package/dist/server/tanstack-start.d.ts +25 -0
- package/dist/server/tanstack-start.js +39 -0
- package/dist/server/tanstack-start.js.map +1 -0
- package/dist/server.cjs +1 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.ts +10 -16
- package/dist/server.js +22 -29
- package/dist/server.js.map +1 -1
- package/dist/{startRecording-DzQo16WK.js → startRecording-CmZjjJoz.js} +2 -2
- package/dist/{startRecording-DzQo16WK.js.map → startRecording-CmZjjJoz.js.map} +1 -1
- package/dist/{startRecording-BGnWDInp.cjs → startRecording-qDCAu4Q0.cjs} +2 -2
- package/dist/{startRecording-BGnWDInp.cjs.map → startRecording-qDCAu4Q0.cjs.map} +1 -1
- package/dist/types/index.d.ts +22 -10
- package/package.json +63 -3
- package/src/components/Chat/stories/ConnectionConfiguration.stories.tsx +6 -8
- package/src/components/assistant-ui/thread.tsx +8 -14
- package/src/components/ui/calendar.tsx +262 -0
- package/src/components/ui/time-range-picker.stories.tsx +249 -0
- package/src/components/ui/time-range-picker.tsx +675 -0
- package/src/hooks/useAuth.ts +59 -7
- package/src/hooks/useFollowOnSuggestions.ts +7 -14
- package/src/hooks/useModel.ts +30 -0
- package/src/index.ts +17 -0
- package/src/lib/api.test.ts +4 -4
- package/src/lib/auth.ts +34 -4
- package/src/lib/models.ts +1 -0
- package/src/server/bun.ts +63 -0
- package/src/server/core.ts +84 -0
- package/src/server/express.ts +60 -0
- package/src/server/fastify.ts +61 -0
- package/src/server/hono.ts +55 -0
- package/src/server/nextjs.ts +58 -0
- package/src/server/tanstack-start.ts +110 -0
- package/src/server.ts +37 -49
- package/src/types/index.ts +25 -9
- package/dist/index-DdrZQXwQ.cjs +0 -147
- package/dist/index-DdrZQXwQ.cjs.map +0 -1
- 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 }
|