Skip to content

Commit e07922e

Browse files
authored
allow assigning event to session in ui (#771)
1 parent 9071d48 commit e07922e

File tree

12 files changed

+312
-290
lines changed

12 files changed

+312
-290
lines changed
Lines changed: 230 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,47 @@
11
import { Trans } from "@lingui/react/macro";
2-
import { useQuery } from "@tanstack/react-query";
2+
import { useMutation, useQuery } from "@tanstack/react-query";
33
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";
56

67
import { useHypr } from "@/contexts";
78
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";
910
import { commands as miscCommands } from "@hypr/plugin-misc";
1011
import { Button } from "@hypr/ui/components/ui/button";
1112
import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover";
1213
import { cn } from "@hypr/ui/lib/utils";
1314
import { useSession } from "@hypr/utils/contexts";
1415
import { formatRelativeWithDay } from "@hypr/utils/datetime";
16+
import { format, isSameDay, subDays } from "date-fns";
1517

1618
interface EventChipProps {
1719
sessionId: string;
1820
}
1921

22+
interface EventWithMeetingLink extends Event {
23+
meetingLink?: string | null;
24+
}
25+
2026
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("");
2230

2331
const { sessionCreatedAt } = useSession(sessionId, (s) => ({
2432
sessionCreatedAt: s.session.created_at,
2533
}));
2634

2735
const event = useQuery({
2836
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) {
3240
return null;
3341
}
3442

35-
const meetingLink = await miscCommands.parseMeetingLink(event.note);
36-
return { ...event, meetingLink };
43+
const meetingLink = await miscCommands.parseMeetingLink(eventData.note);
44+
return { ...eventData, meetingLink };
3745
},
3846
});
3947

@@ -46,6 +54,56 @@ export function EventChip({ sessionId }: EventChipProps) {
4654
},
4755
});
4856

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+
49107
const handleClickCalendar = () => {
50108
if (calendar.data) {
51109
if (calendar.data.platform === "Apple") {
@@ -54,51 +112,170 @@ export function EventChip({ sessionId }: EventChipProps) {
54112
}
55113
};
56114

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+
57128
const date = event.data?.start_date ?? sessionCreatedAt;
58129

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+
/>
81235
</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+
}
104281
}

0 commit comments

Comments
 (0)