From 002f6bfb4574c738f00d9409a7fb2ceaa14b892b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:29:03 -0400 Subject: [PATCH 1/4] Add calendar event hover previews --- .../calendar/EventDetailPopover.tsx | 76 ++----- .../calendar/EventHoverPreview.test.tsx | 125 ++++++++++++ .../components/calendar/EventHoverPreview.tsx | 193 ++++++++++++++++++ .../app/components/calendar/MonthView.tsx | 4 +- .../app/components/calendar/WeekView.tsx | 7 +- .../calendar/app/components/ui/hover-card.tsx | 2 + .../calendar/app/lib/event-meeting.test.ts | 81 ++++++++ templates/calendar/app/lib/event-meeting.ts | 51 +++++ ...week-events-to-preview-time-location-at.md | 6 + 9 files changed, 486 insertions(+), 59 deletions(-) create mode 100644 templates/calendar/app/components/calendar/EventHoverPreview.test.tsx create mode 100644 templates/calendar/app/components/calendar/EventHoverPreview.tsx create mode 100644 templates/calendar/app/components/ui/hover-card.tsx create mode 100644 templates/calendar/app/lib/event-meeting.test.ts create mode 100644 templates/calendar/app/lib/event-meeting.ts create mode 100644 templates/calendar/changelog/2026-08-03-hover-over-month-and-week-events-to-preview-time-location-at.md diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index a8bd3a6729..335b80967e 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -35,6 +35,7 @@ import { RenderedDescription, AutoGrowTextarea, } from "@/components/calendar/EventDescription"; +import { EventHoverPreview } from "@/components/calendar/EventHoverPreview"; import { AttachmentControls, ReminderControls, @@ -96,6 +97,7 @@ import { type ReminderMode, validateAttachmentDrafts, } from "@/lib/event-form-utils"; +import { extractMeetingLink } from "@/lib/event-meeting"; import { isOutOfOfficeEvent } from "@/lib/out-of-office"; import { createEventDetailPopoverToken, @@ -198,54 +200,6 @@ function formatTimeShort(dateStr: string): string { return `${hour12}:${m.toString().padStart(2, "0")} ${period}`; } -/** Extract a Zoom/Meet/Teams link from location or description */ -function extractMeetingLink(event: CalendarEvent): { - url: string; - type: "zoom" | "meet" | "teams" | "link"; - label?: string; - pin?: string; - passcode?: string; -} | null { - if (event.meetingLink) { - return { url: event.meetingLink, type: getMeetingType(event.meetingLink) }; - } - - // Check conferenceData first - if (event.conferenceData?.entryPoints) { - const videoEntry = event.conferenceData.entryPoints.find( - (ep) => ep.entryPointType === "video", - ); - if (videoEntry) { - let type: "zoom" | "meet" | "teams" | "link" = "link"; - if (videoEntry.uri.includes("zoom.us")) type = "zoom"; - else if (videoEntry.uri.includes("meet.google.com")) type = "meet"; - else if (videoEntry.uri.includes("teams.microsoft.com")) type = "teams"; - return { - url: videoEntry.uri, - type, - label: videoEntry.label || undefined, - pin: videoEntry.pin || undefined, - passcode: videoEntry.passcode || undefined, - }; - } - } - - // Fall back to the legacy hangoutLink (Google Meet) - if (event.hangoutLink) { - return { url: event.hangoutLink, type: "meet" }; - } - - // Fall back to text matching - const text = `${event.location || ""} ${event.description || ""}`; - const zoom = text.match(/https?:\/\/[^\s]*zoom\.us\/j\/[^\s)"]*/i); - if (zoom) return { url: zoom[0], type: "zoom" }; - const meet = text.match(/https?:\/\/meet\.google\.com\/[^\s)"]*/i); - if (meet) return { url: meet[0], type: "meet" }; - const teams = text.match(/https?:\/\/teams\.microsoft\.com\/[^\s)"]*/i); - if (teams) return { url: teams[0], type: "teams" }; - return null; -} - function getMeetingLabel( type: "zoom" | "meet" | "teams" | "link", t: ReturnType, @@ -262,13 +216,6 @@ function getMeetingLabel( } } -function getMeetingType(url: string): "zoom" | "meet" | "teams" | "link" { - if (url.includes("zoom.us")) return "zoom"; - if (url.includes("meet.google.com")) return "meet"; - if (url.includes("teams.microsoft.com")) return "teams"; - return "link"; -} - function MeetingLinkSkeleton({ provider }: { provider: "meet" | "zoom" }) { const t = useT(); return ( @@ -500,6 +447,10 @@ interface EventDetailPopoverProps { onDismissNew?: (eventId: string, accountEmail?: string) => void; /** Called after the popover's visible open state changes through its normal lifecycle. */ onOpenChange?: (open: boolean) => void; + /** Adds the compact, read-only Month/Week disclosure without changing click details. */ + showHoverPreview?: boolean; + /** Temporarily suppresses the preview while a parent owns pointer drag state. */ + hoverPreviewDisabled?: boolean; onDraftUpdate?: ( eventId: string, updates: Partial & { @@ -528,6 +479,8 @@ export function EventDetailPopover({ onTitleSave, onDismissNew, onOpenChange, + showHoverPreview = false, + hoverPreviewDisabled = false, onDraftUpdate, onDraftCreate, onDraftDiscard, @@ -1431,6 +1384,8 @@ export function EventDetailPopover({ sidebarEvent?.id === event.id && sidebarEvent.accountEmail === event.accountEmail; const detailsOpen = popoverOpen || sidebarDetailsOpen; + const hoverPreviewEnabled = + showHoverPreview && !isMobile && !isWorkingLocation && !isOutOfOffice; const previousDetailsOpenRef = useRef(false); useEffect(() => { @@ -1449,7 +1404,16 @@ export function EventDetailPopover({ return ( - {children} + {hoverPreviewEnabled ? ( + + {children as React.ReactElement} + + ) : ( + children + )} ({ + useT: + () => + (key: string): string => + key, +})); + +vi.mock("@/components/ui/hover-card", () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardPortal: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ + children, + side, + align, + }: { + children: ReactNode; + side: string; + align: string; + }) => ( + + ), +})); + +function event(overrides: Partial = {}): CalendarEvent { + return { + id: "event-1", + title: "A planning session with a deliberately long title", + description: "", + location: "Room A", + start: "2026-07-10T16:00:00.000Z", + end: "2026-07-10T17:00:00.000Z", + allDay: false, + source: "google", + createdAt: "2026-07-10T15:00:00.000Z", + updatedAt: "2026-07-10T15:00:00.000Z", + attendees: [], + ...overrides, + }; +} + +describe("EventHoverPreview", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("renders the event once beside its trigger with available details", () => { + act(() => { + root.render( + + + , + ); + }); + + const preview = container.querySelector('[data-testid="preview"]'); + expect(preview?.getAttribute("data-side")).toBe("right"); + expect(preview?.getAttribute("data-align")).toBe("center"); + expect( + container.textContent?.match(/deliberately long title/g), + ).toHaveLength(1); + expect(container.textContent).toContain("Room A"); + expect(container.textContent).toContain("Alice, brent@example.com, Sam +1"); + + const meetingLink = container.querySelector( + 'a[href="https://meet.google.com/abc-defg-hij"]', + ); + expect(meetingLink?.textContent).toContain("eventForm.joinMeet"); + expect(meetingLink?.target).toBe("_blank"); + expect(meetingLink?.rel).toBe("noopener noreferrer"); + }); + + it("omits unavailable optional rows and avoids duplicating a meeting URL", () => { + act(() => { + root.render( + + + , + ); + }); + + expect(container.textContent).not.toContain("Join https://zoom.us/j/123"); + expect(container.textContent).toContain("eventForm.joinZoom"); + expect(container.querySelectorAll("svg")).toHaveLength(3); + }); +}); diff --git a/templates/calendar/app/components/calendar/EventHoverPreview.tsx b/templates/calendar/app/components/calendar/EventHoverPreview.tsx new file mode 100644 index 0000000000..481b56591d --- /dev/null +++ b/templates/calendar/app/components/calendar/EventHoverPreview.tsx @@ -0,0 +1,193 @@ +import { useT } from "@agent-native/core/client/i18n"; +import type { CalendarEvent } from "@shared/api"; +import { + IconClock, + IconExternalLink, + IconMapPin, + IconUsers, + IconVideo, +} from "@tabler/icons-react"; +import { format, isSameDay, parseISO } from "date-fns"; +import { + forwardRef, + useEffect, + useMemo, + useState, + type ElementRef, + type HTMLAttributes, + type ReactElement, +} from "react"; + +import { + HoverCard, + HoverCardContent, + HoverCardPortal, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { extractMeetingLink } from "@/lib/event-meeting"; + +interface EventHoverPreviewProps extends HTMLAttributes { + event: CalendarEvent; + children: ReactElement; + disabled?: boolean; +} + +function formatPreviewTime(event: CalendarEvent, allDayLabel: string): string { + const start = parseISO(event.start); + const end = parseISO(event.end); + + if (event.allDay) { + return `${format(start, "EEE, MMM d")} · ${allDayLabel}`; + } + if (isSameDay(start, end)) { + return `${format(start, "EEE, MMM d")} · ${format(start, "h:mm a")}–${format(end, "h:mm a")}`; + } + return `${format(start, "EEE, MMM d · h:mm a")}–${format(end, "EEE, MMM d · h:mm a")}`; +} + +export const EventHoverPreview = forwardRef< + ElementRef, + EventHoverPreviewProps +>(function EventHoverPreview( + { + event, + children, + disabled = false, + onPointerDown, + onPointerUp, + ...triggerProps + }, + forwardedRef, +) { + const t = useT(); + const [open, setOpen] = useState(false); + const [pointerDown, setPointerDown] = useState(false); + const meetingLink = useMemo(() => extractMeetingLink(event), [event]); + const attendees = event.attendees ?? []; + const visibleAttendees = attendees.slice(0, 3); + const attendeeRemainder = attendees.length - visibleAttendees.length; + + useEffect(() => { + if (disabled || pointerDown) setOpen(false); + }, [disabled, pointerDown]); + + useEffect(() => { + if (!pointerDown) return; + const releasePointer = () => setPointerDown(false); + window.addEventListener("pointerup", releasePointer, { once: true }); + window.addEventListener("pointercancel", releasePointer, { once: true }); + return () => { + window.removeEventListener("pointerup", releasePointer); + window.removeEventListener("pointercancel", releasePointer); + }; + }, [pointerDown]); + + const meetingLabel = meetingLink + ? meetingLink.type === "zoom" + ? t("eventForm.joinZoom") + : meetingLink.type === "meet" + ? t("eventForm.joinMeet") + : meetingLink.type === "teams" + ? t("eventForm.joinTeams") + : t("eventForm.joinMeeting") + : null; + + return ( + { + if (!disabled && !pointerDown) setOpen(nextOpen); + }} + openDelay={150} + closeDelay={150} + > + { + setPointerDown(true); + setOpen(false); + onPointerDown?.(pointerEvent); + }} + onPointerUp={(pointerEvent) => { + setPointerDown(false); + onPointerUp?.(pointerEvent); + }} + {...triggerProps} + > + {children} + + + clickEvent.stopPropagation()} + > +

+ {event.title} +

+ +
+
+
+ + {event.location && + (!meetingLink || !event.location.includes(meetingLink.url)) && ( +
+
+ )} + + {attendees.length > 0 && ( +
+
+ )} +
+ + {meetingLink && meetingLabel && ( + pointerEvent.stopPropagation()} + onClick={(clickEvent) => clickEvent.stopPropagation()} + > + + + + )} +
+
+
+ ); +}); diff --git a/templates/calendar/app/components/calendar/MonthView.tsx b/templates/calendar/app/components/calendar/MonthView.tsx index 5ef28b7779..494bb92b20 100644 --- a/templates/calendar/app/components/calendar/MonthView.tsx +++ b/templates/calendar/app/components/calendar/MonthView.tsx @@ -279,6 +279,8 @@ export const MonthView = memo(function MonthView({ onDraftUpdate={onDraftUpdate} onDraftCreate={onDraftCreate} onDraftDiscard={onDraftDiscard} + showHoverPreview + hoverPreviewDisabled={draggingId !== null} >
e.stopPropagation()} @@ -292,7 +294,7 @@ export const MonthView = memo(function MonthView({ } }} className={cn( - "relative", + "relative rounded-sm data-[state=open]:ring-2 data-[state=open]:ring-ring/50 data-[state=open]:ring-offset-1", !isStart && "-ml-1 -mr-1 border-l-2 border-dashed border-current pl-[calc(0.25rem-2px)] opacity-90 sm:-ml-1.5 sm:-mr-1.5 sm:pl-[calc(0.375rem-2px)]", )} diff --git a/templates/calendar/app/components/calendar/WeekView.tsx b/templates/calendar/app/components/calendar/WeekView.tsx index c87fe74776..0f3557a8dc 100644 --- a/templates/calendar/app/components/calendar/WeekView.tsx +++ b/templates/calendar/app/components/calendar/WeekView.tsx @@ -411,7 +411,7 @@ const WeekEventCard = memo(function WeekEventCard({ } }} className={cn( - "absolute overflow-hidden px-1.5 py-0.5 text-left text-[11px] flex flex-col hover:brightness-110 hover:shadow-md group", + "absolute overflow-hidden px-1.5 py-0.5 text-left text-[11px] flex flex-col hover:brightness-110 hover:shadow-md group data-[state=open]:ring-2 data-[state=open]:ring-ring/50", segmentStartsHere ? "rounded-t-md" : "rounded-t-none", isEnd ? "rounded-b-md" : "rounded-b-none", durationMin <= 30 ? "justify-center" : "justify-start", @@ -568,6 +568,8 @@ const WeekEventCard = memo(function WeekEventCard({ onDraftCreate={onDraftCreate} onDraftDiscard={onDraftDiscard} onOpenChange={(open) => onPopoverOpenChange(event, open)} + showHoverPreview + hoverPreviewDisabled={isDragging || isBeingDragged} > {eventButton} @@ -1203,10 +1205,11 @@ export const WeekView = memo(function WeekView({ onDraftUpdate={onDraftUpdate} onDraftCreate={onDraftCreate} onDraftDiscard={onDraftDiscard} + showHoverPreview > + ); + } - if (compact) { return ( ); - } - - return ( - - ); -} + }, +); diff --git a/templates/calendar/app/components/calendar/EventDetailPopover.tsx b/templates/calendar/app/components/calendar/EventDetailPopover.tsx index 335b80967e..e2572d1558 100644 --- a/templates/calendar/app/components/calendar/EventDetailPopover.tsx +++ b/templates/calendar/app/components/calendar/EventDetailPopover.tsx @@ -1409,7 +1409,7 @@ export function EventDetailPopover({ event={event} disabled={hoverPreviewDisabled || detailsOpen} > - {children as React.ReactElement} + {children as React.ReactElement<{ className?: string }>} ) : ( children diff --git a/templates/calendar/app/components/calendar/EventHoverPreview.tsx b/templates/calendar/app/components/calendar/EventHoverPreview.tsx index 481b56591d..305428b922 100644 --- a/templates/calendar/app/components/calendar/EventHoverPreview.tsx +++ b/templates/calendar/app/components/calendar/EventHoverPreview.tsx @@ -9,6 +9,7 @@ import { } from "@tabler/icons-react"; import { format, isSameDay, parseISO } from "date-fns"; import { + cloneElement, forwardRef, useEffect, useMemo, @@ -25,10 +26,11 @@ import { HoverCardTrigger, } from "@/components/ui/hover-card"; import { extractMeetingLink } from "@/lib/event-meeting"; +import { cn } from "@/lib/utils"; interface EventHoverPreviewProps extends HTMLAttributes { event: CalendarEvent; - children: ReactElement; + children: ReactElement<{ className?: string }>; disabled?: boolean; } @@ -91,6 +93,13 @@ export const EventHoverPreview = forwardRef< ? t("eventForm.joinTeams") : t("eventForm.joinMeeting") : null; + const previewOpen = open && !disabled && !pointerDown; + const trigger = cloneElement(children, { + className: cn( + children.props.className, + previewOpen && "ring-2 ring-ring/50 ring-offset-1 ring-offset-background", + ), + }); return ( - {children} + {trigger} ( - {})} - isDraft={draftEventIds.includes(event.id)} - onDraftUpdate={onDraftUpdate} - onDraftCreate={onDraftCreate} - onDraftDiscard={onDraftDiscard} - showHoverPreview - hoverPreviewDisabled={draggingId !== null} + onClick={(e) => e.stopPropagation()} + onDragStart={(e) => { + if (!isStart) return; + const ghost = e.currentTarget.querySelector( + "button", + ) as HTMLElement | null; + if (ghost) { + e.dataTransfer.setDragImage(ghost, 12, 12); + } + }} + className={cn( + "relative rounded-sm", + !isStart && + "-ml-1 -mr-1 border-l-2 border-dashed border-current pl-[calc(0.25rem-2px)] opacity-90 sm:-ml-1.5 sm:-mr-1.5 sm:pl-[calc(0.375rem-2px)]", + )} > -
e.stopPropagation()} - onDragStart={(e) => { - if (!isStart) return; - const ghost = e.currentTarget.querySelector( - "button", - ) as HTMLElement | null; - if (ghost) { - e.dataTransfer.setDragImage(ghost, 12, 12); - } - }} - className={cn( - "relative rounded-sm data-[state=open]:ring-2 data-[state=open]:ring-ring/50 data-[state=open]:ring-offset-1", - !isStart && - "-ml-1 -mr-1 border-l-2 border-dashed border-current pl-[calc(0.25rem-2px)] opacity-90 sm:-ml-1.5 sm:-mr-1.5 sm:pl-[calc(0.375rem-2px)]", - )} + {})} + isDraft={draftEventIds.includes(event.id)} + onDraftUpdate={onDraftUpdate} + onDraftCreate={onDraftCreate} + onDraftDiscard={onDraftDiscard} + showHoverPreview + hoverPreviewDisabled={draggingId !== null} > - {continuesNext && ( - - )} -
-
+ + {continuesNext && ( + + )} +
))} {!isLoading && dayOccurrences.length > (isMobile ? 2 : 3) && (