diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 28f9539c..64bd9779 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -303,15 +303,6 @@ box-shadow: none; } -.upcoming-event-row { - background-color: transparent; - transition: background-color 120ms ease; -} - -.upcoming-event-row:hover { - background-color: var(--gm-bg-pill-hover); -} - .gmail-row-selected { background: var(--gm-bg-row-selected); box-shadow: inset 2px 0 0 var(--gm-accent); diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index b4e369e7..ef9a5864 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -212,6 +213,42 @@ function formatEventTimeRange(event: UpcomingEvent): string { return `${start} – ${end}` } +// Compact range for the upcoming list: drops the leading meridiem when both +// ends share it ("9:00 – 11:00 AM" instead of "9:00 AM – 11:00 AM"). +function formatEventTimeRangeCompact(event: UpcomingEvent): string { + if (event.isAllDay) return 'All day' + const startStr = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (!event.end) return startStr + const sameDay = localDateKey(event.start) === localDateKey(event.end) + if (!sameDay) return formatEventTimeRange(event) + const endStr = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + const meridiemRe = /\s*[AP]M$/i + const startMer = startStr.match(meridiemRe)?.[0]?.trim().toUpperCase() + const endMer = endStr.match(meridiemRe)?.[0]?.trim().toUpperCase() + if (startMer && endMer && startMer === endMer) { + return `${startStr.replace(meridiemRe, '')} – ${endStr}` + } + return `${startStr} – ${endStr}` +} + +// Whether a timed event is happening right now. +function isEventNow(event: UpcomingEvent): boolean { + if (event.isAllDay) return false + const now = Date.now() + const start = event.start.getTime() + const end = event.end ? event.end.getTime() : start + 30 * 60 * 1000 + return start <= now && now < end +} + +// Human label for the conferencing provider behind an event's join link. +function meetingPlatformLabel(link: string | null): string | null { + if (!link) return null + if (/zoom\.us|zoomgov\.com/i.test(link)) return 'Zoom' + if (/teams\.(?:microsoft|live)\.com/i.test(link)) return 'Teams' + if (/meet\.google\.com/i.test(link)) return 'Meet' + return 'Video call' +} + function formatEventDetailTime(event: UpcomingEvent): string { if (!event.isAllDay) { const date = event.start.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }) @@ -431,8 +468,9 @@ function UpcomingEvents() { break } }) - // Refresh on the hour so day labels and "ended" filtering stay current. - const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000) + // Refresh every minute so the "now" highlight, day labels, and "ended" + // filtering stay current without waiting on a calendar sync. + const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 1000) return () => { cleanup() clearInterval(tick) @@ -462,10 +500,7 @@ function UpcomingEvents() { Coming up {loading && events.length === 0 ? null : ( - + {totalVisible} {totalVisible === 1 ? 'event' : 'events'} )} @@ -491,16 +526,12 @@ function UpcomingEvents() { ) : error ? (
{error}
) : ( -
- {visibleDays.map((day, idx) => ( - + {visibleDays.map((day) => ( + ))}
@@ -511,63 +542,57 @@ function UpcomingEvents() { ) } -function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) { +function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) { const dayNum = day.date.getDate() const month = day.date.toLocaleDateString([], { month: 'short' }) const weekday = day.date.toLocaleDateString([], { weekday: 'short' }) + const count = day.events.length return ( -
-
- - {dayNum} - - - - {month} - {isToday ? ( - - ) : null} +
+
+
+ {dayNum} + + {month} · {weekday} - {weekday} + {isToday ? ( + + Today + + ) : null} +
+ + {count} {count === 1 ? 'event' : 'events'}
-
- {day.events.length === 0 ? ( -
- - {isToday ? 'No events today' : 'No events'} -
- ) : ( - day.events.map((ev) => ) - )} -
+ + {count === 0 ? ( +
+ {isToday ? 'No events today' : 'No events'} +
+ ) : ( + day.events.map((ev, idx) => ( + + )) + )}
) } -function UpcomingEventItem({ event }: { event: UpcomingEvent }) { - const [open, setOpen] = useState(false) +function NowBadge() { + return ( + + Now + + ) +} +function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) { + const [open, setOpen] = useState(false) + const isNow = isEventNow(event) + const platform = meetingPlatformLabel(event.conferenceLink) + const subtitle = platform ?? event.location const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary return ( @@ -578,31 +603,29 @@ function UpcomingEventItem({ event }: { event: UpcomingEvent }) { tabIndex={0} title={titleAndLocation} className={cn( - 'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer', + 'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors', + !isLast && 'border-b', + isNow ? 'bg-muted' : 'hover:bg-muted/50', )} - style={{ color: 'var(--gm-text)', minHeight: 40 }} > - - - - {event.summary} - - - {formatEventTimeRange(event)} - {event.location ? · {event.location} : null} - + + {formatEventTimeRangeCompact(event)} -
+ + + + {event.summary} + + {isNow ? : null} + + {subtitle ? ( + + {platform ? + ) : null} + +
{event.conferenceLink ? ( triggerMeetingCapture(event, true)} @@ -613,16 +636,9 @@ function UpcomingEventItem({ event }: { event: UpcomingEvent }) { type="button" onClick={(e) => { e.stopPropagation(); triggerMeetingCapture(event, false) }} onMouseDown={(e) => e.stopPropagation()} - className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors" - style={{ - background: 'var(--gm-bg-pill)', - color: 'var(--gm-text)', - border: '1px solid var(--gm-border)', - }} - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill-hover)' }} - onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--gm-bg-pill)' }} + className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent" > - + Take notes )} @@ -650,7 +666,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose sideOffset={6} className="w-[min(380px,calc(100vw-32px))] rounded-lg p-0 shadow-xl" style={{ - backgroundColor: 'var(--popover, #fff)', + backgroundColor: 'var(--muted, #f4f4f5)', borderColor: 'var(--border, #e4e4e7)', color: 'var(--popover-foreground, #09090b)', }} @@ -664,7 +680,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose 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)' }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }} > @@ -677,7 +693,7 @@ function EventDetailsPopover({ event, onClose }: { event: UpcomingEvent; onClose style={{ color: 'var(--muted-foreground, #71717a)' }} aria-label="Close event details" title="Close" - onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--muted, #f4f4f5)' }} + onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--background, #ffffff)' }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }} > @@ -771,41 +787,46 @@ function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: { onNotesOnly: () => void }) { const [open, setOpen] = useState(false) - const ref = useRef(null) + const containerRef = useRef(null) + const menuRef = useRef(null) + // Fixed-position coords for the portaled menu so it isn't clipped by the + // calendar card's `overflow-hidden`. + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null) + + const updatePos = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect() + if (!rect) return + setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right }) + }, []) useEffect(() => { if (!open) return + updatePos() const handler = (e: MouseEvent) => { const target = e.target - if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) { - setOpen(false) - } + if (!(target instanceof globalThis.Node)) return + if (containerRef.current?.contains(target) || menuRef.current?.contains(target)) return + setOpen(false) } document.addEventListener('mousedown', handler) - return () => document.removeEventListener('mousedown', handler) - }, [open]) + window.addEventListener('resize', updatePos) + window.addEventListener('scroll', updatePos, true) + return () => { + document.removeEventListener('mousedown', handler) + window.removeEventListener('resize', updatePos) + window.removeEventListener('scroll', updatePos, true) + } + }, [open, updatePos]) return ( -
+
- {open && ( -
- -
- )} + {open && menuPos + ? createPortal( +
+ +
, + document.body, + ) + : null}
) }