1
1
import { Trans } from "@lingui/react/macro" ;
2
- import { useQuery } from "@tanstack/react-query" ;
2
+ import { useMutation , useQuery } from "@tanstack/react-query" ;
3
3
import { openUrl } from "@tauri-apps/plugin-opener" ;
4
- import { CalendarIcon , VideoIcon } from "lucide-react" ;
4
+ import { CalendarIcon , SearchIcon , SpeechIcon , VideoIcon , XIcon } from "lucide-react" ;
5
+ import { useState } from "react" ;
5
6
6
7
import { useHypr } from "@/contexts" ;
7
8
import { commands as appleCalendarCommands } from "@hypr/plugin-apple-calendar" ;
8
- import { commands as dbCommands } from "@hypr/plugin-db" ;
9
+ import { commands as dbCommands , type Event } from "@hypr/plugin-db" ;
9
10
import { commands as miscCommands } from "@hypr/plugin-misc" ;
10
11
import { Button } from "@hypr/ui/components/ui/button" ;
11
12
import { Popover , PopoverContent , PopoverTrigger } from "@hypr/ui/components/ui/popover" ;
12
13
import { cn } from "@hypr/ui/lib/utils" ;
13
14
import { useSession } from "@hypr/utils/contexts" ;
14
15
import { formatRelativeWithDay } from "@hypr/utils/datetime" ;
16
+ import { format , isSameDay , subDays } from "date-fns" ;
15
17
16
18
interface EventChipProps {
17
19
sessionId : string ;
18
20
}
19
21
22
+ interface EventWithMeetingLink extends Event {
23
+ meetingLink ?: string | null ;
24
+ }
25
+
20
26
export function EventChip ( { sessionId } : EventChipProps ) {
21
- const { onboardingSessionId } = useHypr ( ) ;
27
+ const { userId, onboardingSessionId } = useHypr ( ) ;
28
+ const [ isEventSelectorOpen , setIsEventSelectorOpen ] = useState ( false ) ;
29
+ const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
22
30
23
31
const { sessionCreatedAt } = useSession ( sessionId , ( s ) => ( {
24
32
sessionCreatedAt : s . session . created_at ,
25
33
} ) ) ;
26
34
27
35
const event = useQuery ( {
28
36
queryKey : [ "event" , sessionId ] ,
29
- queryFn : async ( ) => {
30
- const event = await dbCommands . sessionGetEvent ( sessionId ) ;
31
- if ( ! event ) {
37
+ queryFn : async ( ) : Promise < EventWithMeetingLink | null > => {
38
+ const eventData = await dbCommands . sessionGetEvent ( sessionId ) ;
39
+ if ( ! eventData ) {
32
40
return null ;
33
41
}
34
42
35
- const meetingLink = await miscCommands . parseMeetingLink ( event . note ) ;
36
- return { ...event , meetingLink } ;
43
+ const meetingLink = await miscCommands . parseMeetingLink ( eventData . note ) ;
44
+ return { ...eventData , meetingLink } ;
37
45
} ,
38
46
} ) ;
39
47
@@ -46,6 +54,56 @@ export function EventChip({ sessionId }: EventChipProps) {
46
54
} ,
47
55
} ) ;
48
56
57
+ const eventsInPastWithoutAssignedSession = useQuery ( {
58
+ queryKey : [ "events-in-past-without-assigned-session" , userId , sessionId ] ,
59
+ queryFn : async ( ) : Promise < Event [ ] > => {
60
+ const events = await dbCommands . listEvents ( {
61
+ limit : 100 ,
62
+ user_id : userId ,
63
+ type : "dateRange" ,
64
+ start : subDays ( new Date ( ) , 28 ) . toISOString ( ) ,
65
+ end : new Date ( ) . toISOString ( ) ,
66
+ } ) ;
67
+
68
+ const sessions = await Promise . all (
69
+ events . map ( ( eventItem ) => dbCommands . getSession ( { calendarEventId : eventItem . id } ) ) ,
70
+ ) ;
71
+
72
+ const ret = events . filter ( ( eventItem ) => {
73
+ const isLinkedToAnotherSession = sessions . find ( ( s ) =>
74
+ s ?. calendar_event_id === eventItem . id && s . id !== sessionId
75
+ ) ;
76
+ return ! isLinkedToAnotherSession ;
77
+ } ) ;
78
+ return ret ;
79
+ } ,
80
+ enabled : isEventSelectorOpen && ! event . data ,
81
+ } ) ;
82
+
83
+ const assignEvent = useMutation ( {
84
+ mutationFn : async ( eventId : string ) => {
85
+ await dbCommands . setSessionEvent ( sessionId , eventId ) ;
86
+ } ,
87
+ onSuccess : ( ) => {
88
+ event . refetch ( ) ;
89
+ eventsInPastWithoutAssignedSession . refetch ( ) ;
90
+ } ,
91
+ } ) ;
92
+
93
+ const detachEvent = useMutation ( {
94
+ mutationFn : async ( ) => {
95
+ await dbCommands . setSessionEvent ( sessionId , null ) ;
96
+ } ,
97
+ onSuccess : ( ) => {
98
+ event . refetch ( ) ;
99
+ eventsInPastWithoutAssignedSession . refetch ( ) ;
100
+ setIsEventSelectorOpen ( false ) ;
101
+ } ,
102
+ onError : ( error ) => {
103
+ console . error ( "Failed to detach session event:" , error ) ;
104
+ } ,
105
+ } ) ;
106
+
49
107
const handleClickCalendar = ( ) => {
50
108
if ( calendar . data ) {
51
109
if ( calendar . data . platform === "Apple" ) {
@@ -54,51 +112,170 @@ export function EventChip({ sessionId }: EventChipProps) {
54
112
}
55
113
} ;
56
114
115
+ const handleSelectEvent = async ( eventIdToLink : string ) => {
116
+ assignEvent . mutate ( eventIdToLink , {
117
+ onSuccess : ( ) => {
118
+ event . refetch ( ) ;
119
+ eventsInPastWithoutAssignedSession . refetch ( ) ;
120
+ setIsEventSelectorOpen ( false ) ;
121
+ } ,
122
+ onError : ( error ) => {
123
+ console . error ( "Failed to set session event:" , error ) ;
124
+ } ,
125
+ } ) ;
126
+ } ;
127
+
57
128
const date = event . data ?. start_date ?? sessionCreatedAt ;
58
129
59
- return (
60
- < Popover >
61
- < PopoverTrigger
62
- disabled = { ! event . data || onboardingSessionId === sessionId }
63
- >
64
- < div
65
- className = { cn (
66
- "flex flex-row items-center gap-2 rounded-md px-2 py-1.5" ,
67
- event . data
68
- && onboardingSessionId !== sessionId
69
- && "hover:bg-neutral-100" ,
70
- ) }
71
- >
72
- { event . data ?. meetingLink ? < VideoIcon size = { 14 } /> : < CalendarIcon size = { 14 } /> }
73
- < p className = "text-xs" > { formatRelativeWithDay ( date ) } </ p >
74
- </ div >
75
- </ PopoverTrigger >
76
- < PopoverContent align = "start" className = "shadow-lg w-72" >
77
- < div className = "flex flex-col gap-2" >
78
- < div className = "font-semibold" > { event . data ?. name } </ div >
79
- < div className = "text-sm text-neutral-600 whitespace-pre-wrap break-words max-h-24 overflow-y-auto" >
80
- { event . data ?. note }
130
+ if ( onboardingSessionId === sessionId ) {
131
+ return (
132
+ < div className = "flex flex-row items-center gap-2 rounded-md px-2 py-1.5" >
133
+ < CalendarIcon size = { 14 } />
134
+ < p className = "text-xs" > { formatRelativeWithDay ( date ) } </ p >
135
+ </ div >
136
+ ) ;
137
+ }
138
+
139
+ if ( event . data ) {
140
+ return (
141
+ < Popover >
142
+ < PopoverTrigger >
143
+ < div
144
+ className = { cn (
145
+ "flex flex-row items-center gap-2 rounded-md px-2 py-1.5" ,
146
+ "hover:bg-neutral-100" ,
147
+ ) }
148
+ >
149
+ { event . data . meetingLink ? < VideoIcon size = { 14 } /> : < SpeechIcon size = { 14 } /> }
150
+ < p className = "text-xs" > { formatRelativeWithDay ( date ) } </ p >
151
+ </ div >
152
+ </ PopoverTrigger >
153
+
154
+ < PopoverContent align = "start" className = "shadow-lg w-80 relative" >
155
+ { ( ( ) => {
156
+ const startDateObj = new Date ( event . data . start_date ) ;
157
+ const endDateObj = new Date ( event . data . end_date ) ;
158
+ const formattedStartDate = formatRelativeWithDay ( startDateObj . toISOString ( ) ) ;
159
+ const startTime = format ( startDateObj , "p" ) ;
160
+ const endTime = format ( endDateObj , "p" ) ;
161
+ let dateString ;
162
+ if ( isSameDay ( startDateObj , endDateObj ) ) {
163
+ dateString = `${ formattedStartDate } , ${ startTime } - ${ endTime } ` ;
164
+ } else {
165
+ const formattedEndDate = formatRelativeWithDay ( endDateObj . toISOString ( ) ) ;
166
+ dateString = `${ formattedStartDate } , ${ startTime } - ${ formattedEndDate } , ${ endTime } ` ;
167
+ }
168
+
169
+ return (
170
+ < div className = "flex flex-col gap-2" >
171
+ < button
172
+ onClick = { ( ) => detachEvent . mutate ( ) }
173
+ className = "absolute top-4 right-4 p-1 bg-red-100 text-white rounded-full hover:bg-red-500 transition-colors z-10"
174
+ aria-label = "Detach event"
175
+ >
176
+ < XIcon size = { 12 } />
177
+ </ button >
178
+ < div className = "font-semibold" > { event . data . name } </ div >
179
+ < div className = "text-sm text-neutral-500" > { dateString } </ div >
180
+
181
+ < div className = "flex gap-2" >
182
+ { event . data . meetingLink && (
183
+ < Button
184
+ onClick = { ( ) => {
185
+ const meetingLink = event . data ?. meetingLink ;
186
+ if ( typeof meetingLink === "string" ) {
187
+ openUrl ( meetingLink ) ;
188
+ }
189
+ } }
190
+ className = "flex-1"
191
+ >
192
+ < VideoIcon size = { 16 } />
193
+ < Trans > Join meeting</ Trans >
194
+ </ Button >
195
+ ) }
196
+
197
+ < Button variant = "outline" onClick = { handleClickCalendar } disabled = { ! calendar . data } className = "flex-1" >
198
+ < Trans > View in calendar</ Trans >
199
+ </ Button >
200
+ </ div >
201
+
202
+ { event . data . note && (
203
+ < div className = "border-t pt-2 text-sm text-neutral-600 whitespace-pre-wrap break-words max-h-40 overflow-y-auto scrollbar-none" >
204
+ { event . data . note }
205
+ </ div >
206
+ ) }
207
+ </ div >
208
+ ) ;
209
+ } ) ( ) }
210
+ </ PopoverContent >
211
+ </ Popover >
212
+ ) ;
213
+ } else {
214
+ return (
215
+ < Popover open = { isEventSelectorOpen } onOpenChange = { setIsEventSelectorOpen } >
216
+ < PopoverTrigger asChild >
217
+ < div className = "flex flex-row items-center gap-2 rounded-md px-2 py-1.5 hover:bg-neutral-100 cursor-pointer" >
218
+ < CalendarIcon size = { 14 } />
219
+ < p className = "text-xs" > { formatRelativeWithDay ( sessionCreatedAt ) } </ p >
220
+ </ div >
221
+ </ PopoverTrigger >
222
+
223
+ < PopoverContent align = "start" className = "shadow-lg w-80" >
224
+ < div className = "flex items-center w-full px-2 py-1.5 gap-2 rounded-md bg-neutral-50 border border-neutral-200 transition-colors mb-2" >
225
+ < span className = "text-neutral-500 flex-shrink-0" >
226
+ < SearchIcon className = "size-4" />
227
+ </ span >
228
+ < input
229
+ type = "text"
230
+ placeholder = "Search past events..."
231
+ value = { searchQuery }
232
+ onChange = { ( e ) => setSearchQuery ( e . target . value ) }
233
+ className = "w-full bg-transparent text-sm focus:outline-none placeholder:text-neutral-400"
234
+ />
81
235
</ div >
82
- { event . data ?. meetingLink && (
83
- < Button
84
- variant = "outline"
85
- className = "flex items-center gap-2 text-xs overflow-hidden text-ellipsis whitespace-nowrap"
86
- onClick = { ( ) => {
87
- const meetingLink = event . data ?. meetingLink ;
88
- if ( typeof meetingLink === "string" ) {
89
- openUrl ( meetingLink ) ;
90
- }
91
- } }
92
- >
93
- < VideoIcon size = { 14 } />
94
- < span className = "truncate" > Join meeting</ span >
95
- </ Button >
96
- ) }
97
- < Button variant = "outline" onClick = { handleClickCalendar } >
98
- < Trans > View in calendar</ Trans >
99
- </ Button >
100
- </ div >
101
- </ PopoverContent >
102
- </ Popover >
103
- ) ;
236
+
237
+ { ( ( ) => {
238
+ if ( eventsInPastWithoutAssignedSession . isLoading ) {
239
+ return (
240
+ < div className = "p-4 text-center text-sm text-neutral-500" >
241
+ < Trans > Loading events...</ Trans >
242
+ </ div >
243
+ ) ;
244
+ }
245
+
246
+ const filteredEvents = ( eventsInPastWithoutAssignedSession . data || [ ] ) . filter ( ( ev : Event ) =>
247
+ ev . name . toLowerCase ( ) . includes ( searchQuery . toLowerCase ( ) )
248
+ ) ;
249
+
250
+ if ( filteredEvents . length === 0 ) {
251
+ return (
252
+ < div className = "p-4 text-center text-sm text-neutral-500" >
253
+ < Trans > No past events found.</ Trans >
254
+ </ div >
255
+ ) ;
256
+ }
257
+
258
+ return (
259
+ < div className = "max-h-60 overflow-y-auto pt-0" >
260
+ { filteredEvents . map ( ( linkableEv : Event ) => (
261
+ < button
262
+ key = { linkableEv . id }
263
+ onClick = { ( ) => handleSelectEvent ( linkableEv . id ) }
264
+ className = "flex flex-col items-start p-2 hover:bg-neutral-100 text-left w-full rounded-md"
265
+ >
266
+ < p className = "text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap w-full" >
267
+ { linkableEv . name }
268
+ </ p >
269
+ < p className = "text-xs text-neutral-500" >
270
+ { formatRelativeWithDay ( linkableEv . start_date ) }
271
+ </ p >
272
+ </ button >
273
+ ) ) }
274
+ </ div >
275
+ ) ;
276
+ } ) ( ) }
277
+ </ PopoverContent >
278
+ </ Popover >
279
+ ) ;
280
+ }
104
281
}
0 commit comments