diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index bccafd34..6a3ffbee 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -254,6 +254,15 @@ 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/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7d022d9b..a32869d6 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -3296,6 +3296,7 @@ function App() { setIsGraphOpen(false) setIsBrowserOpen(false) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setSelectedBackgroundTask(null) @@ -3326,6 +3327,7 @@ function App() { setIsMeetingsOpen(true) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsEmailOpen(false) setSelectedBackgroundTask(null) setExpandedFrom(null) setIsRightPaneMaximized(false) @@ -3396,6 +3398,7 @@ function App() { setIsMeetingsOpen(true) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) + setIsEmailOpen(false) ensureMeetingsFileTab() return case 'live-notes': @@ -3420,6 +3423,7 @@ function App() { setIsRightPaneMaximized(false) setSelectedBackgroundTask(null) setIsSuggestedTopicsOpen(false) + setIsMeetingsOpen(false) setIsLiveNotesOpen(false) setIsBgTasksOpen(false) setIsEmailOpen(true) diff --git a/apps/x/apps/renderer/src/components/meetings-view.tsx b/apps/x/apps/renderer/src/components/meetings-view.tsx index 52260dcc..28f32312 100644 --- a/apps/x/apps/renderer/src/components/meetings-view.tsx +++ b/apps/x/apps/renderer/src/components/meetings-view.tsx @@ -1,11 +1,15 @@ -import { useCallback, useEffect, useState } from 'react' -import { Loader2, Mic, Square } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Calendar, ChevronDown, Loader2, Mic, Square, Video } from 'lucide-react' import { Button } from '@/components/ui/button' import { formatRelativeTime } from '@/lib/relative-time' +import { extractConferenceLink } from '@/lib/calendar-event' +import { cn } from '@/lib/utils' import type { MeetingTranscriptionState } from '@/hooks/useMeetingTranscription' const MEETINGS_ROOT = 'knowledge/Meetings' +const CALENDAR_DIR = 'calendar_sync' +const UPCOMING_MAX_DAYS = 4 // today + next 3 type MeetingNoteRow = { path: string @@ -25,6 +29,537 @@ function isMeetingPath(path: string | undefined): boolean { return typeof path === 'string' && (path === MEETINGS_ROOT || path.startsWith(`${MEETINGS_ROOT}/`)) } +function isCalendarPath(path: string | undefined): boolean { + return typeof path === 'string' && (path === CALENDAR_DIR || path.startsWith(`${CALENDAR_DIR}/`)) +} + +type RawCalendarEvent = { + id?: string + summary?: string + start?: { dateTime?: string; date?: string } + end?: { dateTime?: string; date?: string } + location?: string + htmlLink?: string + status?: string + attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }> + conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> } + hangoutLink?: string + conferenceLink?: string +} + +type UpcomingEvent = { + id: string + summary: string + start: Date + end: Date | null + isAllDay: boolean + location: string | null + htmlLink: string | null + conferenceLink: string | null + source: string // workspace path to the calendar_sync JSON + rawStart: { dateTime?: string; date?: string } | undefined + rawEnd: { dateTime?: string; date?: string } | undefined + dateKey: string // YYYY-MM-DD (local) +} + +type DayGroup = { + dateKey: string + date: Date // local start-of-day + events: UpcomingEvent[] +} + +function startOfDay(d: Date): Date { + const out = new Date(d) + out.setHours(0, 0, 0, 0) + return out +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d) + out.setDate(out.getDate() + n) + return out +} + +function localDateKey(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +// Parse an all-day calendar date string ("YYYY-MM-DD") into a local Date at midnight. +function parseAllDayDate(s: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s) + if (!m) return null + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) +} + +function normalizeEvent(raw: RawCalendarEvent, sourcePath: string): UpcomingEvent | null { + if (raw.status === 'cancelled') return null + const declined = raw.attendees?.find((a) => a.self)?.responseStatus === 'declined' + if (declined) return null + + const allDayStart = raw.start?.date + const timedStart = raw.start?.dateTime + const isAllDay = !timedStart && Boolean(allDayStart) + + let start: Date | null = null + let end: Date | null = null + if (timedStart) { + start = new Date(timedStart) + end = raw.end?.dateTime ? new Date(raw.end.dateTime) : null + } else if (allDayStart) { + start = parseAllDayDate(allDayStart) + // Google's all-day end is exclusive (next day at 00:00) — keep as-is. + end = raw.end?.date ? parseAllDayDate(raw.end.date) : null + } + if (!start || Number.isNaN(start.getTime())) return null + + const conferenceLink = extractConferenceLink(raw as unknown as Record) ?? null + + return { + id: raw.id ?? sourcePath, + summary: raw.summary?.trim() || '(No title)', + start, + end, + isAllDay, + location: raw.location?.trim() || null, + htmlLink: raw.htmlLink ?? null, + conferenceLink, + source: sourcePath, + rawStart: raw.start, + rawEnd: raw.end, + dateKey: localDateKey(start), + } +} + +function triggerMeetingCapture(event: UpcomingEvent, openConference: boolean) { + window.__pendingCalendarEvent = { + summary: event.summary, + start: event.rawStart, + end: event.rawEnd, + location: event.location ?? undefined, + htmlLink: event.htmlLink ?? undefined, + conferenceLink: event.conferenceLink ?? undefined, + source: event.source, + } + if (openConference && event.conferenceLink) { + window.open(event.conferenceLink, '_blank') + } + window.dispatchEvent(new Event('calendar-block:join-meeting')) +} + +// Always show today (anchor). For days within the window after today, include +// only those that actually have events — skip empty days. +function selectVisibleDays(allDays: DayGroup[]): DayGroup[] { + if (allDays.length === 0) return [] + const out: DayGroup[] = [allDays[0]] + const cap = Math.min(allDays.length, UPCOMING_MAX_DAYS) + for (let i = 1; i < cap; i++) { + if (allDays[i].events.length > 0) out.push(allDays[i]) + } + return out +} + +function buildDayWindow(now: Date): DayGroup[] { + const today = startOfDay(now) + return Array.from({ length: UPCOMING_MAX_DAYS }, (_, i) => { + const date = addDays(today, i) + return { dateKey: localDateKey(date), date, events: [] } + }) +} + +function formatEventTimeRange(event: UpcomingEvent): string { + if (event.isAllDay) return 'All day' + const start = event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + if (!event.end) return start + // If start and end are on different days, show date+time on both ends. + const sameDay = localDateKey(event.start) === localDateKey(event.end) + if (!sameDay) { + const startLong = event.start.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + const endLong = event.end.toLocaleString([], { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }) + return `${startLong} – ${endLong}` + } + const end = event.end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + return `${start} – ${end}` +} + +function UpcomingEvents() { + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshTick, setRefreshTick] = useState(0) + + const loadEvents = useCallback(async () => { + setLoading(true) + try { + const exists = await window.ipc.invoke('workspace:exists', { path: CALENDAR_DIR }) + if (!exists.exists) { + setEvents([]) + setError(null) + return + } + const entries = await window.ipc.invoke('workspace:readdir', { + path: CALENDAR_DIR, + opts: { recursive: false, includeHidden: false, includeStats: false }, + }) + const jsonEntries = entries.filter((e) => e.kind === 'file' && e.name.endsWith('.json')) + + const now = new Date() + const todayStart = startOfDay(now) + const windowEnd = addDays(todayStart, UPCOMING_MAX_DAYS) // exclusive + + const settled = await Promise.allSettled( + jsonEntries.map(async (entry): Promise => { + const result = await window.ipc.invoke('workspace:readFile', { + path: entry.path, + encoding: 'utf8', + }) + const raw = JSON.parse(result.data) as RawCalendarEvent + const ev = normalizeEvent(raw, entry.path) + if (!ev) return null + // Event must overlap the [now, windowEnd) range — i.e. not already ended, + // and not start after the window closes. + const effectiveEnd = ev.end ?? (ev.isAllDay ? addDays(ev.start, 1) : ev.start) + if (effectiveEnd <= now) return null + if (ev.start >= windowEnd) return null + return ev + }), + ) + + const collected: UpcomingEvent[] = [] + for (const r of settled) { + if (r.status === 'fulfilled' && r.value) collected.push(r.value) + } + collected.sort((a, b) => { + if (a.isAllDay !== b.isAllDay) return a.isAllDay ? -1 : 1 + return a.start.getTime() - b.start.getTime() + }) + setEvents(collected) + setError(null) + } catch (err) { + console.error('Failed to load upcoming events:', err) + setError('Could not load upcoming events.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadEvents() + }, [loadEvents, refreshTick]) + + useEffect(() => { + let timeout: ReturnType | null = null + const scheduleReload = () => { + if (timeout) clearTimeout(timeout) + timeout = setTimeout(() => { + timeout = null + setRefreshTick((t) => t + 1) + }, 250) + } + const cleanup = window.ipc.on('workspace:didChange', (event) => { + switch (event.type) { + case 'created': + case 'changed': + case 'deleted': + if (isCalendarPath(event.path)) scheduleReload() + break + case 'moved': + if (isCalendarPath(event.from) || isCalendarPath(event.to)) scheduleReload() + break + case 'bulkChanged': + if (!event.paths || event.paths.some(isCalendarPath)) scheduleReload() + break + } + }) + // Refresh on the hour so day labels and "ended" filtering stay current. + const tick = setInterval(() => setRefreshTick((t) => t + 1), 60 * 60 * 1000) + return () => { + cleanup() + clearInterval(tick) + if (timeout) clearTimeout(timeout) + } + }, []) + + const visibleDays = useMemo(() => { + const window = buildDayWindow(new Date()) + const byKey = new Map(window.map((d) => [d.dateKey, d])) + for (const ev of events) { + byKey.get(ev.dateKey)?.events.push(ev) + } + return selectVisibleDays(window) + }, [events]) + + const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0) + const now = new Date() + const todayKey = localDateKey(now) + + return ( +
+
+
+

+ + Coming up +

+ {loading && events.length === 0 ? null : ( + + {totalVisible} {totalVisible === 1 ? 'event' : 'events'} + + )} +
+ + {loading && events.length === 0 ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( +
+ {visibleDays.map((day, idx) => ( + + ))} +
+ )} +
+
+ ) +} + +function UpcomingDayRow({ day, isToday, isLast }: { day: DayGroup; isToday: boolean; isLast: boolean }) { + const dayNum = day.date.getDate() + const month = day.date.toLocaleDateString([], { month: 'short' }) + const weekday = day.date.toLocaleDateString([], { weekday: 'short' }) + + return ( +
+
+ + {dayNum} + + + + {month} + {isToday ? ( + + ) : null} + + {weekday} + +
+
+ {day.events.length === 0 ? ( +
+ + {isToday ? 'No events today' : 'No events'} +
+ ) : ( + day.events.map((ev) => ) + )} +
+
+ ) +} + +function UpcomingEventItem({ event }: { event: UpcomingEvent }) { + const handleOpen = useCallback(() => { + if (event.htmlLink) window.open(event.htmlLink, '_blank') + }, [event.htmlLink]) + + const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleOpen() + } + }} + title={titleAndLocation} + className={cn( + 'upcoming-event-row group flex w-full items-center gap-3 px-3 py-2 text-left cursor-pointer', + )} + style={{ color: 'var(--gm-text)', minHeight: 40 }} + > + + + + {event.summary} + + + {formatEventTimeRange(event)} + {event.location ? · {event.location} : null} + + +
+ {event.conferenceLink ? ( + triggerMeetingCapture(event, true)} + onNotesOnly={() => triggerMeetingCapture(event, false)} + /> + ) : ( + + )} +
+
+ ) +} + +function SplitJoinButton({ onJoinAndNotes, onNotesOnly }: { + onJoinAndNotes: () => void + onNotesOnly: () => void +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + const target = e.target + if (ref.current && target instanceof globalThis.Node && !ref.current.contains(target)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + return ( +
+ + + {open && ( +
+ +
+ )} +
+ ) +} + function formatMeetingName(name: string): string { return name.replace(/\.md$/i, '').replace(/_/g, ' ') } @@ -176,20 +711,22 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee

- All your meeting notes. + Upcoming events and meeting notes.

-
+
+ +
{loading ? ( -
+
) : error ? ( -
+
{error}
) : notes.length === 0 ? ( -
+
@@ -234,6 +771,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
)} +
)