show calendar details on click

This commit is contained in:
Arjun 2026-05-25 14:20:37 +05:30
parent 8aa0c1763a
commit f1d3b7b825

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react' import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { SettingsDialog } from '@/components/settings-dialog' import { SettingsDialog } from '@/components/settings-dialog'
import { formatRelativeTime } from '@/lib/relative-time' import { formatRelativeTime } from '@/lib/relative-time'
import { extractConferenceLink } from '@/lib/calendar-event' import { extractConferenceLink } from '@/lib/calendar-event'
@ -40,14 +41,32 @@ type RawCalendarEvent = {
start?: { dateTime?: string; date?: string } start?: { dateTime?: string; date?: string }
end?: { dateTime?: string; date?: string } end?: { dateTime?: string; date?: string }
location?: string location?: string
description?: string
htmlLink?: string htmlLink?: string
status?: string status?: string
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }> creator?: CalendarPerson
organizer?: CalendarPerson
attendees?: CalendarAttendee[]
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
hangoutLink?: string hangoutLink?: string
conferenceLink?: string conferenceLink?: string
} }
type CalendarPerson = {
email?: string
displayName?: string
self?: boolean
}
type CalendarAttendee = CalendarPerson & {
responseStatus?: string
optional?: boolean
}
type DescriptionPart =
| { type: 'text'; text: string }
| { type: 'link'; text: string; href: string }
type UpcomingEvent = { type UpcomingEvent = {
id: string id: string
summary: string summary: string
@ -55,8 +74,12 @@ type UpcomingEvent = {
end: Date | null end: Date | null
isAllDay: boolean isAllDay: boolean
location: string | null location: string | null
description: string | null
htmlLink: string | null htmlLink: string | null
conferenceLink: string | null conferenceLink: string | null
creator: CalendarPerson | null
organizer: CalendarPerson | null
attendees: CalendarAttendee[]
source: string // workspace path to the calendar_sync JSON source: string // workspace path to the calendar_sync JSON
rawStart: { dateTime?: string; date?: string } | undefined rawStart: { dateTime?: string; date?: string } | undefined
rawEnd: { dateTime?: string; date?: string } | undefined rawEnd: { dateTime?: string; date?: string } | undefined
@ -125,8 +148,12 @@ function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEven
end, end,
isAllDay, isAllDay,
location: raw.location?.trim() || null, location: raw.location?.trim() || null,
description: raw.description?.trim() || null,
htmlLink: raw.htmlLink ?? null, htmlLink: raw.htmlLink ?? null,
conferenceLink, conferenceLink,
creator: raw.creator ?? null,
organizer: raw.organizer ?? null,
attendees: raw.attendees ?? [],
source: sourcePath, source: sourcePath,
rawStart: raw.start, rawStart: raw.start,
rawEnd: raw.end, rawEnd: raw.end,
@ -185,6 +212,115 @@ function formatEventTimeRange(event: UpcomingEvent): string {
return `${start} ${end}` return `${start} ${end}`
} }
function formatEventDetailTime(event: UpcomingEvent): string {
if (!event.isAllDay) {
const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
return `${date}, ${formatEventTimeRange(event)}`
}
const start = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
if (!event.end) return `${start}, all day`
const exclusiveEnd = addDays(event.end, -1)
if (localDateKey(exclusiveEnd) === localDateKey(event.start)) return `${start}, all day`
const end = exclusiveEnd.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })
return `${start} ${end}, all day`
}
function personLabel(person: CalendarPerson | null | undefined): string | null {
if (!person) return null
return person.displayName?.trim() || person.email?.trim() || null
}
function attendeeLabel(attendee: CalendarAttendee): string | null {
const label = personLabel(attendee)
if (!label) return null
if (attendee.self) return `${label} (you)`
return label
}
function normalizeDescriptionParts(parts: DescriptionPart[]): DescriptionPart[] {
const normalized: DescriptionPart[] = []
for (const part of parts) {
const text = part.text.replace(/\n{3,}/g, '\n\n')
if (!text) continue
const previous = normalized[normalized.length - 1]
if (previous?.type === 'text' && part.type === 'text') {
previous.text += text
} else if (part.type === 'link') {
normalized.push({ ...part, text })
} else {
normalized.push({ type: 'text', text })
}
}
return normalized
}
function isSafeDescriptionHref(value: string): boolean {
try {
const url = new URL(value, window.location.href)
return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'
} catch {
return false
}
}
function linkifyText(value: string): DescriptionPart[] {
const parts: DescriptionPart[] = []
const urlRe = /\bhttps?:\/\/[^\s<>"')\]]+|\bwww\.[^\s<>"')\]]+/gi
let lastIndex = 0
for (const match of value.matchAll(urlRe)) {
const raw = match[0]
const index = match.index ?? 0
if (index > lastIndex) parts.push({ type: 'text', text: value.slice(lastIndex, index) })
const href = raw.startsWith('www.') ? `https://${raw}` : raw
parts.push({ type: 'link', text: raw, href })
lastIndex = index + raw.length
}
if (lastIndex < value.length) parts.push({ type: 'text', text: value.slice(lastIndex) })
return parts
}
function parseDescriptionParts(value: string): DescriptionPart[] {
const withLineBreaks = value.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/\s*(p|div|li|tr|h[1-6])\s*>/gi, '\n')
if (typeof DOMParser === 'undefined') {
return normalizeDescriptionParts(linkifyText(withLineBreaks.replace(/<[^>]*>/g, '').trim()))
}
const doc = new DOMParser().parseFromString(withLineBreaks, 'text/html')
const parts: DescriptionPart[] = []
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
parts.push(...linkifyText(node.textContent ?? ''))
return
}
if (!(node instanceof HTMLElement)) return
if (node.tagName === 'A') {
const href = node.getAttribute('href') ?? ''
const text = node.textContent?.trim() || href
if (href && isSafeDescriptionHref(href)) {
parts.push({ type: 'link', text, href })
return
}
}
if (node.tagName === 'BR') {
parts.push({ type: 'text', text: '\n' })
return
}
node.childNodes.forEach(visit)
if (/^(P|DIV|LI|TR|H[1-6])$/.test(node.tagName)) {
parts.push({ type: 'text', text: '\n' })
}
}
doc.body.childNodes.forEach(visit)
return normalizeDescriptionParts(parts).map((part, index, all) => {
if (index === 0 || index === all.length - 1) return { ...part, text: part.text.trim() }
return part
}).filter((part) => part.text.length > 0)
}
function UpcomingEvents() { function UpcomingEvents() {
const [events, setEvents] = useState<UpcomingEvent[]>([]) const [events, setEvents] = useState<UpcomingEvent[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -430,23 +566,16 @@ function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: bool
} }
function UpcomingEventItem({ event }: { event: UpcomingEvent }) { function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
const handleOpen = useCallback(() => { const [open, setOpen] = useState(false)
if (event.htmlLink) window.open(event.htmlLink, '_blank')
}, [event.htmlLink])
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
return ( return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleOpen()
}
}}
title={titleAndLocation} title={titleAndLocation}
className={cn( className={cn(
'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer', 'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer',
@ -499,6 +628,141 @@ function UpcomingEventItem({ event }: { event: UpcomingEvent }) {
)} )}
</div> </div>
</div> </div>
</PopoverTrigger>
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
</Popover>
)
}
function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose: () => void }) {
const organizer = personLabel(event.organizer) ?? personLabel(event.creator)
const attendees = event.attendees.map(attendeeLabel).filter((label): label is string => Boolean(label))
const descriptionParts = event.description ? parseDescriptionParts(event.description) : []
const handleMeetingCapture = (openConference: boolean) => {
onClose()
triggerMeetingCapture(event, openConference)
}
return (
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl"
style={{
backgroundColor: 'var(--popover, #fff)',
borderColor: 'var(--border, #e4e4e7)',
color: 'var(--popover-foreground, #09090b)',
}}
>
<div className="flex items-center justify-end gap-1 border-b px-3 py-2" style={{ borderColor: 'var(--border, #e4e4e7)' }}>
{event.htmlLink ? (
<button
type="button"
onClick={() => window.open(event.htmlLink!, '_blank')}
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--muted-foreground, #71717a)' }}
aria-label="Open in Google Calendar"
title="Open in Google Calendar"
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<ExternalLink className="size-4" />
</button>
) : null}
<button
type="button"
onClick={onClose}
className="inline-flex size-8 items-center justify-center rounded-md transition-colors"
style={{ color: 'var(--muted-foreground, #71717a)' }}
aria-label="Close event details"
title="Close"
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<X className="size-4" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<div className="flex gap-3">
<span
aria-hidden
className="mt-1.5 h-3 w-3 shrink-0 rounded-sm"
style={{ background: 'var(--primary, #18181b)' }}
/>
<div className="min-w-0">
<h4 className="break-words text-[20px] font-normal leading-6" style={{ color: 'var(--foreground, #09090b)' }}>
{event.summary}
</h4>
</div>
</div>
<EventDetailRow icon={<Clock className="size-4" />} value={formatEventDetailTime(event)} />
{event.location ? <EventDetailRow icon={<MapPin className="size-4" />} value={event.location} /> : null}
{organizer ? <EventDetailRow icon={<UserRound className="size-4" />} value={`Organizer: ${organizer}`} /> : null}
{attendees.length > 0 ? (
<EventDetailRow
icon={<UsersRound className="size-4" />}
value={attendees.slice(0, 8).join(', ') + (attendees.length > 8 ? `, +${attendees.length - 8} more` : '')}
/>
) : null}
{event.conferenceLink ? (
<div className="flex gap-3">
<Video className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => handleMeetingCapture(true)}>
Join & take notes
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
Take notes only
</Button>
</div>
</div>
) : (
<div className="flex gap-3">
<Mic className="mt-1 size-4 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }} />
<Button type="button" size="sm" variant="outline" onClick={() => handleMeetingCapture(false)}>
Take notes
</Button>
</div>
)}
{descriptionParts.length > 0 ? (
<div className="flex gap-3">
<span className="mt-1 size-4 shrink-0" />
<div className="max-h-40 overflow-auto whitespace-pre-wrap break-words text-sm leading-5" style={{ color: 'var(--foreground, #27272a)' }}>
{descriptionParts.map((part, index) => {
if (part.type === 'text') return <span key={index}>{part.text}</span>
return (
<a
key={index}
href={part.href}
onClick={(e) => {
e.preventDefault()
window.open(part.href, '_blank')
}}
className="underline underline-offset-2"
style={{ color: 'var(--primary, #18181b)' }}
>
{part.text}
</a>
)
})}
</div>
</div>
) : null}
</div>
</PopoverContent>
)
}
function EventDetailRow({ icon, value }: { icon: React.ReactNode; value: string }) {
return (
<div className="flex gap-3 text-sm leading-5">
<span className="mt-0.5 shrink-0" style={{ color: 'var(--muted-foreground, #71717a)' }}>{icon}</span>
<span className="min-w-0 break-words" style={{ color: 'var(--foreground, #27272a)' }}>{value}</span>
</div>
) )
} }