stemtrace 0.1.0__py3-none-any.whl

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 (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "stemtrace-ui",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview",
10
+ "lint": "biome lint .",
11
+ "format": "biome format .",
12
+ "check": "biome check .",
13
+ "fix": "biome check --write .",
14
+ "generate": "tsr generate",
15
+ "typecheck": "tsr generate && tsc --noEmit",
16
+ "test": "playwright test",
17
+ "test:ui": "playwright test --ui",
18
+ "test:report": "playwright show-report"
19
+ },
20
+ "dependencies": {
21
+ "@tanstack/react-query": "^5.60.0",
22
+ "@tanstack/react-router": "^1.82.0",
23
+ "@xyflow/react": "^12.3.0",
24
+ "clsx": "^2.1.1",
25
+ "date-fns": "^4.1.0",
26
+ "react": "^18.3.1",
27
+ "react-day-picker": "^9.13.0",
28
+ "react-dom": "^18.3.1"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "^2.3.10",
32
+ "@playwright/test": "^1.57.0",
33
+ "@tailwindcss/postcss": "^4.1.18",
34
+ "@tanstack/router-cli": "^1.144.0",
35
+ "@tanstack/router-devtools": "^1.82.0",
36
+ "@tanstack/router-plugin": "^1.79.0",
37
+ "@types/node": "^25.0.3",
38
+ "@types/react": "^18.3.12",
39
+ "@types/react-dom": "^18.3.1",
40
+ "@vitejs/plugin-react": "^5.1.2",
41
+ "postcss": "^8.4.49",
42
+ "tailwindcss": "^4.1.18",
43
+ "typescript": "~5.9.3",
44
+ "vite": "^7.3.0"
45
+ }
46
+ }
@@ -0,0 +1,40 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ /**
4
+ * Playwright configuration for stemtrace UI E2E tests.
5
+ *
6
+ * Run tests:
7
+ * npx playwright test
8
+ * npx playwright test --ui # Interactive mode
9
+ *
10
+ * Prerequisites:
11
+ * docker compose -f docker-compose.e2e.yml up -d --wait
12
+ */
13
+ export default defineConfig({
14
+ testDir: './tests',
15
+ fullyParallel: true,
16
+ forbidOnly: !!process.env.CI,
17
+ retries: process.env.CI ? 2 : 0,
18
+ workers: process.env.CI ? 1 : undefined,
19
+ reporter: [['html', { open: 'never' }], ['list']],
20
+
21
+ use: {
22
+ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8000',
23
+ trace: 'on-first-retry',
24
+ screenshot: 'only-on-failure',
25
+ },
26
+
27
+ projects: [
28
+ {
29
+ name: 'chromium',
30
+ use: { ...devices['Desktop Chrome'] },
31
+ },
32
+ ],
33
+
34
+ // Don't start a web server - we expect docker-compose to be running
35
+ // webServer: {
36
+ // command: 'docker compose -f docker-compose.e2e.yml up',
37
+ // url: 'http://localhost:8000/api/health',
38
+ // reuseExistingServer: true,
39
+ // },
40
+ })
@@ -0,0 +1,5 @@
1
+ export default {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * API client for stemtrace backend.
3
+ */
4
+
5
+ // Base URL determined by environment
6
+ export const API_BASE = import.meta.env.DEV ? '/api' : '/stemtrace/api'
7
+
8
+ // Types matching backend schemas
9
+ export type NodeType = 'TASK' | 'GROUP' | 'CHORD'
10
+
11
+ export interface TaskEvent {
12
+ task_id: string
13
+ name: string
14
+ state: string
15
+ timestamp: string
16
+ parent_id: string | null
17
+ root_id: string | null
18
+ group_id: string | null
19
+ trace_id: string | null
20
+ retries: number
21
+ // Enhanced event data
22
+ args: unknown[] | null
23
+ kwargs: Record<string, unknown> | null
24
+ result: unknown | null
25
+ exception: string | null
26
+ traceback: string | null
27
+ }
28
+
29
+ export interface RegisteredTask {
30
+ name: string
31
+ signature: string | null
32
+ docstring: string | null
33
+ module: string | null
34
+ bound: boolean
35
+ }
36
+
37
+ export interface TaskRegistryResponse {
38
+ tasks: RegisteredTask[]
39
+ total: number
40
+ }
41
+
42
+ export interface TaskNode {
43
+ task_id: string
44
+ name: string
45
+ state: string
46
+ node_type: NodeType
47
+ group_id: string | null
48
+ chord_id: string | null
49
+ parent_id: string | null
50
+ children: string[]
51
+ events: TaskEvent[]
52
+ first_seen: string | null
53
+ last_updated: string | null
54
+ duration_ms: number | null
55
+ }
56
+
57
+ export interface TaskListResponse {
58
+ tasks: TaskNode[]
59
+ total: number
60
+ limit: number
61
+ offset: number
62
+ }
63
+
64
+ export interface TaskDetailResponse {
65
+ task: TaskNode
66
+ children: TaskNode[]
67
+ }
68
+
69
+ export interface GraphNode {
70
+ task_id: string
71
+ name: string
72
+ state: string
73
+ node_type: NodeType
74
+ group_id: string | null
75
+ chord_id: string | null
76
+ parent_id: string | null
77
+ children: string[]
78
+ duration_ms: number | null
79
+ first_seen: string | null
80
+ last_updated: string | null
81
+ }
82
+
83
+ export interface GraphResponse {
84
+ root_id: string
85
+ nodes: Record<string, GraphNode>
86
+ }
87
+
88
+ export interface GraphListResponse {
89
+ graphs: GraphNode[]
90
+ total: number
91
+ limit: number
92
+ offset: number
93
+ }
94
+
95
+ export interface HealthResponse {
96
+ status: string
97
+ version: string
98
+ consumer_running: boolean
99
+ websocket_connections: number
100
+ node_count: number
101
+ }
102
+
103
+ export interface FetchTasksParams {
104
+ limit?: number
105
+ offset?: number
106
+ state?: string
107
+ name?: string
108
+ from_date?: string
109
+ to_date?: string
110
+ }
111
+
112
+ export interface FetchGraphsParams {
113
+ limit?: number
114
+ offset?: number
115
+ from_date?: string
116
+ to_date?: string
117
+ }
118
+
119
+ // API functions
120
+ export async function fetchTasks(params?: FetchTasksParams): Promise<TaskListResponse> {
121
+ const searchParams = new URLSearchParams()
122
+ if (params?.limit) searchParams.set('limit', params.limit.toString())
123
+ if (params?.offset) searchParams.set('offset', params.offset.toString())
124
+ if (params?.state) searchParams.set('state', params.state)
125
+ if (params?.name) searchParams.set('name', params.name)
126
+ if (params?.from_date) searchParams.set('from_date', params.from_date)
127
+ if (params?.to_date) searchParams.set('to_date', params.to_date)
128
+
129
+ const url = `${API_BASE}/tasks${searchParams.toString() ? `?${searchParams}` : ''}`
130
+ const response = await fetch(url)
131
+ if (!response.ok) throw new Error('Failed to fetch tasks')
132
+ return response.json()
133
+ }
134
+
135
+ export async function fetchTask(taskId: string): Promise<TaskDetailResponse> {
136
+ const response = await fetch(`${API_BASE}/tasks/${taskId}`)
137
+ if (!response.ok) throw new Error('Failed to fetch task')
138
+ return response.json()
139
+ }
140
+
141
+ export async function fetchGraphs(params?: FetchGraphsParams): Promise<GraphListResponse> {
142
+ const searchParams = new URLSearchParams()
143
+ if (params?.limit) searchParams.set('limit', params.limit.toString())
144
+ if (params?.offset) searchParams.set('offset', params.offset.toString())
145
+ if (params?.from_date) searchParams.set('from_date', params.from_date)
146
+ if (params?.to_date) searchParams.set('to_date', params.to_date)
147
+
148
+ const url = `${API_BASE}/graphs${searchParams.toString() ? `?${searchParams}` : ''}`
149
+ const response = await fetch(url)
150
+ if (!response.ok) throw new Error('Failed to fetch graphs')
151
+ return response.json()
152
+ }
153
+
154
+ export async function fetchGraph(rootId: string): Promise<GraphResponse> {
155
+ const response = await fetch(`${API_BASE}/graphs/${rootId}`)
156
+ if (!response.ok) throw new Error('Failed to fetch graph')
157
+ return response.json()
158
+ }
159
+
160
+ export async function fetchHealth(): Promise<HealthResponse> {
161
+ const response = await fetch(`${API_BASE}/health`)
162
+ if (!response.ok) throw new Error('Failed to fetch health')
163
+ return response.json()
164
+ }
165
+
166
+ export async function fetchTaskRegistry(query?: string): Promise<TaskRegistryResponse> {
167
+ const url = query
168
+ ? `${API_BASE}/tasks/registry?query=${encodeURIComponent(query)}`
169
+ : `${API_BASE}/tasks/registry`
170
+ const response = await fetch(url)
171
+ if (!response.ok) throw new Error('Failed to fetch task registry')
172
+ return response.json()
173
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * TanStack Query hooks for API data fetching.
3
+ *
4
+ * Polling is only enabled when WebSocket is disconnected.
5
+ * When WS is connected, real-time updates come via WS invalidation.
6
+ */
7
+
8
+ import { type InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
9
+ import { useWebSocketContext } from '@/hooks/WebSocketContext'
10
+ import {
11
+ type FetchGraphsParams,
12
+ type FetchTasksParams,
13
+ fetchGraph,
14
+ fetchGraphs,
15
+ fetchHealth,
16
+ fetchTask,
17
+ fetchTaskRegistry,
18
+ fetchTasks,
19
+ type GraphListResponse,
20
+ type GraphResponse,
21
+ type HealthResponse,
22
+ type TaskDetailResponse,
23
+ type TaskListResponse,
24
+ type TaskRegistryResponse,
25
+ } from './client'
26
+
27
+ // Polling interval when WebSocket is disconnected
28
+ const POLL_INTERVAL = 5000
29
+ const PAGE_SIZE = 50
30
+
31
+ export function useTasks(params?: Omit<FetchTasksParams, 'limit' | 'offset'>) {
32
+ const { isConnected } = useWebSocketContext()
33
+
34
+ return useQuery<TaskListResponse>({
35
+ queryKey: ['tasks', params],
36
+ queryFn: () => fetchTasks({ ...params, limit: PAGE_SIZE }),
37
+ // Only poll when WS is disconnected
38
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
39
+ })
40
+ }
41
+
42
+ export function useTasksInfinite(params?: Omit<FetchTasksParams, 'limit' | 'offset'>) {
43
+ const { isConnected } = useWebSocketContext()
44
+
45
+ return useInfiniteQuery<
46
+ TaskListResponse,
47
+ Error,
48
+ InfiniteData<TaskListResponse>,
49
+ unknown[],
50
+ number
51
+ >({
52
+ queryKey: ['tasks-infinite', params],
53
+ queryFn: ({ pageParam }) =>
54
+ fetchTasks({
55
+ ...params,
56
+ limit: PAGE_SIZE,
57
+ offset: pageParam,
58
+ }),
59
+ initialPageParam: 0,
60
+ getNextPageParam: (lastPage) => {
61
+ const nextOffset = lastPage.offset + lastPage.limit
62
+ return nextOffset < lastPage.total ? nextOffset : undefined
63
+ },
64
+ // Only poll first page when WS is disconnected
65
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
66
+ })
67
+ }
68
+
69
+ export function useTask(taskId: string) {
70
+ const { isConnected } = useWebSocketContext()
71
+
72
+ return useQuery<TaskDetailResponse>({
73
+ queryKey: ['tasks', taskId],
74
+ queryFn: () => fetchTask(taskId),
75
+ enabled: !!taskId,
76
+ // Only poll when WS is disconnected
77
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
78
+ })
79
+ }
80
+
81
+ export function useGraphs(params?: Omit<FetchGraphsParams, 'limit' | 'offset'>) {
82
+ const { isConnected } = useWebSocketContext()
83
+
84
+ return useQuery<GraphListResponse>({
85
+ queryKey: ['graphs', params],
86
+ queryFn: () => fetchGraphs({ ...params, limit: PAGE_SIZE }),
87
+ // Only poll when WS is disconnected
88
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
89
+ })
90
+ }
91
+
92
+ export function useGraphsInfinite(params?: Omit<FetchGraphsParams, 'limit' | 'offset'>) {
93
+ const { isConnected } = useWebSocketContext()
94
+
95
+ return useInfiniteQuery<
96
+ GraphListResponse,
97
+ Error,
98
+ InfiniteData<GraphListResponse>,
99
+ unknown[],
100
+ number
101
+ >({
102
+ queryKey: ['graphs-infinite', params],
103
+ queryFn: ({ pageParam }) =>
104
+ fetchGraphs({
105
+ ...params,
106
+ limit: PAGE_SIZE,
107
+ offset: pageParam,
108
+ }),
109
+ initialPageParam: 0,
110
+ getNextPageParam: (lastPage) => {
111
+ const nextOffset = lastPage.offset + lastPage.limit
112
+ return nextOffset < lastPage.total ? nextOffset : undefined
113
+ },
114
+ // Only poll first page when WS is disconnected
115
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
116
+ })
117
+ }
118
+
119
+ export function useGraph(rootId: string) {
120
+ const { isConnected } = useWebSocketContext()
121
+
122
+ return useQuery<GraphResponse>({
123
+ queryKey: ['graphs', rootId],
124
+ queryFn: () => fetchGraph(rootId),
125
+ enabled: !!rootId,
126
+ // Only poll when WS is disconnected
127
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
128
+ })
129
+ }
130
+
131
+ export function useHealth() {
132
+ const { isConnected } = useWebSocketContext()
133
+
134
+ return useQuery<HealthResponse>({
135
+ queryKey: ['health'],
136
+ queryFn: fetchHealth,
137
+ // Sync with data polling interval so status changes are consistent
138
+ refetchInterval: isConnected ? false : POLL_INTERVAL,
139
+ })
140
+ }
141
+
142
+ export function useTaskRegistry(query?: string) {
143
+ const { isConnected } = useWebSocketContext()
144
+
145
+ return useQuery<TaskRegistryResponse>({
146
+ queryKey: ['taskRegistry', query],
147
+ queryFn: () => fetchTaskRegistry(query),
148
+ // Registry refreshes less frequently
149
+ refetchInterval: isConnected ? false : 30000,
150
+ })
151
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Date range picker with preset ranges.
3
+ */
4
+
5
+ import {
6
+ endOfDay,
7
+ endOfMonth,
8
+ endOfWeek,
9
+ format,
10
+ startOfDay,
11
+ startOfMonth,
12
+ startOfWeek,
13
+ subDays,
14
+ subMonths,
15
+ subWeeks,
16
+ } from 'date-fns'
17
+ import { useEffect, useRef, useState } from 'react'
18
+ import { type DateRange, DayPicker } from 'react-day-picker'
19
+
20
+ interface DateRangePickerProps {
21
+ value: DateRange | undefined
22
+ onChange: (range: DateRange | undefined) => void
23
+ }
24
+
25
+ interface Preset {
26
+ label: string
27
+ getValue: () => DateRange
28
+ }
29
+
30
+ const presets: Preset[] = [
31
+ {
32
+ label: 'Today',
33
+ getValue: () => ({
34
+ from: startOfDay(new Date()),
35
+ to: endOfDay(new Date()),
36
+ }),
37
+ },
38
+ {
39
+ label: 'Yesterday',
40
+ getValue: () => {
41
+ const yesterday = subDays(new Date(), 1)
42
+ return {
43
+ from: startOfDay(yesterday),
44
+ to: endOfDay(yesterday),
45
+ }
46
+ },
47
+ },
48
+ {
49
+ label: 'This Week',
50
+ getValue: () => ({
51
+ from: startOfWeek(new Date(), { weekStartsOn: 1 }),
52
+ to: endOfWeek(new Date(), { weekStartsOn: 1 }),
53
+ }),
54
+ },
55
+ {
56
+ label: 'Last Week',
57
+ getValue: () => {
58
+ const lastWeek = subWeeks(new Date(), 1)
59
+ return {
60
+ from: startOfWeek(lastWeek, { weekStartsOn: 1 }),
61
+ to: endOfWeek(lastWeek, { weekStartsOn: 1 }),
62
+ }
63
+ },
64
+ },
65
+ {
66
+ label: 'Last 7 Days',
67
+ getValue: () => ({
68
+ from: startOfDay(subDays(new Date(), 6)),
69
+ to: endOfDay(new Date()),
70
+ }),
71
+ },
72
+ {
73
+ label: 'Last 30 Days',
74
+ getValue: () => ({
75
+ from: startOfDay(subDays(new Date(), 29)),
76
+ to: endOfDay(new Date()),
77
+ }),
78
+ },
79
+ {
80
+ label: 'This Month',
81
+ getValue: () => ({
82
+ from: startOfMonth(new Date()),
83
+ to: endOfMonth(new Date()),
84
+ }),
85
+ },
86
+ {
87
+ label: 'Last Month',
88
+ getValue: () => {
89
+ const lastMonth = subMonths(new Date(), 1)
90
+ return {
91
+ from: startOfMonth(lastMonth),
92
+ to: endOfMonth(lastMonth),
93
+ }
94
+ },
95
+ },
96
+ ]
97
+
98
+ export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
99
+ const [isOpen, setIsOpen] = useState(false)
100
+ const containerRef = useRef<HTMLDivElement>(null)
101
+
102
+ // Close on click outside
103
+ useEffect(() => {
104
+ function handleClickOutside(event: MouseEvent) {
105
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
106
+ setIsOpen(false)
107
+ }
108
+ }
109
+ document.addEventListener('mousedown', handleClickOutside)
110
+ return () => document.removeEventListener('mousedown', handleClickOutside)
111
+ }, [])
112
+
113
+ const formatRange = () => {
114
+ if (!value?.from) return 'Select date range'
115
+ if (!value.to) return format(value.from, 'MMM d, yyyy')
116
+ if (format(value.from, 'yyyy-MM-dd') === format(value.to, 'yyyy-MM-dd')) {
117
+ return format(value.from, 'MMM d, yyyy')
118
+ }
119
+ return `${format(value.from, 'MMM d')} - ${format(value.to, 'MMM d, yyyy')}`
120
+ }
121
+
122
+ const handlePreset = (preset: Preset) => {
123
+ onChange(preset.getValue())
124
+ setIsOpen(false)
125
+ }
126
+
127
+ const handleClear = () => {
128
+ onChange(undefined)
129
+ setIsOpen(false)
130
+ }
131
+
132
+ return (
133
+ <div ref={containerRef} className="relative">
134
+ <button
135
+ type="button"
136
+ onClick={() => setIsOpen(!isOpen)}
137
+ className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors whitespace-nowrap ${
138
+ value?.from
139
+ ? 'bg-slate-700 border-slate-600 text-slate-100'
140
+ : 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-600'
141
+ }`}
142
+ >
143
+ <CalendarIcon />
144
+ <span>{formatRange()}</span>
145
+ {value?.from && (
146
+ <button
147
+ type="button"
148
+ onClick={(e) => {
149
+ e.stopPropagation()
150
+ handleClear()
151
+ }}
152
+ className="ml-1 text-slate-500 hover:text-slate-300"
153
+ >
154
+ <XIcon />
155
+ </button>
156
+ )}
157
+ </button>
158
+
159
+ {isOpen && (
160
+ <div className="absolute top-full mt-2 right-0 z-50 flex bg-slate-900 rounded-xl border border-slate-700 shadow-2xl overflow-hidden">
161
+ {/* Presets sidebar */}
162
+ <div className="w-36 border-r border-slate-700 py-2">
163
+ {presets.map((preset) => (
164
+ <button
165
+ key={preset.label}
166
+ type="button"
167
+ onClick={() => handlePreset(preset)}
168
+ className="w-full px-3 py-1.5 text-left text-sm text-slate-300 hover:bg-slate-800 hover:text-slate-100 transition-colors"
169
+ >
170
+ {preset.label}
171
+ </button>
172
+ ))}
173
+ </div>
174
+
175
+ {/* Calendar */}
176
+ <div className="p-3 relative">
177
+ <DayPicker
178
+ mode="range"
179
+ selected={value}
180
+ onSelect={onChange}
181
+ numberOfMonths={2}
182
+ classNames={{
183
+ root: 'text-slate-200',
184
+ months: 'flex gap-4',
185
+ month: 'space-y-3',
186
+ month_caption: 'flex justify-center items-center h-8',
187
+ caption_label: 'text-sm font-medium text-slate-200',
188
+ nav: 'absolute top-3 left-0 right-0 flex justify-between px-1',
189
+ button_previous:
190
+ 'p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200',
191
+ button_next: 'p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-slate-200',
192
+ weekdays: 'flex',
193
+ weekday: 'w-8 text-center text-xs font-medium text-slate-500',
194
+ week: 'flex',
195
+ day: 'w-8 h-8 text-center text-sm p-0',
196
+ day_button:
197
+ 'w-8 h-8 rounded-md hover:bg-slate-800 transition-colors text-slate-300 hover:text-slate-100 disabled:text-slate-600 disabled:hover:bg-transparent',
198
+ selected: 'bg-emerald-600 text-white hover:bg-emerald-600',
199
+ range_start: 'rounded-l-md',
200
+ range_end: 'rounded-r-md',
201
+ range_middle: 'bg-emerald-600/30 rounded-none',
202
+ today: 'font-bold text-emerald-400',
203
+ disabled: 'text-slate-600',
204
+ }}
205
+ />
206
+ </div>
207
+ </div>
208
+ )}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ function CalendarIcon() {
214
+ return (
215
+ <svg
216
+ className="w-4 h-4"
217
+ fill="none"
218
+ viewBox="0 0 24 24"
219
+ stroke="currentColor"
220
+ aria-hidden="true"
221
+ >
222
+ <path
223
+ strokeLinecap="round"
224
+ strokeLinejoin="round"
225
+ strokeWidth={2}
226
+ d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
227
+ />
228
+ </svg>
229
+ )
230
+ }
231
+
232
+ function XIcon() {
233
+ return (
234
+ <svg
235
+ className="w-3 h-3"
236
+ fill="none"
237
+ viewBox="0 0 24 24"
238
+ stroke="currentColor"
239
+ aria-hidden="true"
240
+ >
241
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
242
+ </svg>
243
+ )
244
+ }