From d47cca4fa2b509dd51848047c1626071856738ac Mon Sep 17 00:00:00 2001 From: Manik Aggarwal Date: Thu, 7 May 2026 09:37:04 +0000 Subject: [PATCH 1/2] feat(daily): add Calendar widget showing today's Google Calendar events - New native widget CalendarWidget renders today's events sorted by start time, with all-day events at the top - New API route GET /api/v1/calendar/today-events fetches events via the google-calendar integration using the user's local timezone - Registers "calendar" in NATIVE_WIDGETS so users can add it via the widget picker in the Daily section - Updates google-calendar list_events to return structured JSON instead of a formatted string so the route can parse event objects cleanly Co-Authored-By: Claude Sonnet 4.6 --- .../daily/calendar-widget.client.tsx | 169 ++++++++++++++++++ .../daily/daily-widget-grid.client.tsx | 8 + .../routes/api.v1.calendar.today-events.tsx | 118 ++++++++++++ integrations/google-calendar/src/mcp/index.ts | 20 +-- 4 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/app/components/daily/calendar-widget.client.tsx create mode 100644 apps/webapp/app/routes/api.v1.calendar.today-events.tsx diff --git a/apps/webapp/app/components/daily/calendar-widget.client.tsx b/apps/webapp/app/components/daily/calendar-widget.client.tsx new file mode 100644 index 000000000..21d82749b --- /dev/null +++ b/apps/webapp/app/components/daily/calendar-widget.client.tsx @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useState } from "react"; +import { CalendarDays, LoaderCircle, MapPin, RefreshCw } from "lucide-react"; +import type { CalendarEvent } from "~/routes/api.v1.calendar.today-events"; + +interface FetchResult { + events: CalendarEvent[]; + connected: boolean; + error?: boolean; +} + +function formatTime(dateTime: string): string { + return new Date(dateTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }); +} + +export function CalendarWidget() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchEvents = useCallback(async () => { + setLoading(true); + try { + const tz = encodeURIComponent( + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + const res = await fetch(`/api/v1/calendar/today-events?timezone=${tz}`); + if (!res.ok) throw new Error("fetch failed"); + setData(await res.json()); + } catch { + setData({ events: [], connected: true, error: true }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + const allDayEvents = data?.events.filter((e) => e.allDay) ?? []; + const timedEvents = data?.events.filter((e) => !e.allDay) ?? []; + + return ( +
+ {/* Header */} +
+ + + Today's Events + + {!loading && ( + + )} +
+ + {/* Loading */} + {loading && ( +
+ +
+ )} + + {/* Not connected */} + {!loading && data && !data.connected && ( +
+

+ Connect Google Calendar to see your events. +

+ + Connect + +
+ )} + + {/* Error */} + {!loading && data?.connected && data.error && ( +
+

+ Could not load events. +

+ +
+ )} + + {/* Empty */} + {!loading && data?.connected && !data.error && data.events.length === 0 && ( +
+ +

No events today

+
+ )} + + {/* Event list */} + {!loading && data?.connected && !data.error && data.events.length > 0 && ( +
+ {allDayEvents.map((event) => ( + + ))} + {timedEvents.map((event) => ( + + ))} +
+ )} +
+ ); +} + +function EventRow({ event }: { event: CalendarEvent }) { + const timeLabel = event.allDay + ? "All day" + : event.start.dateTime + ? formatTime(event.start.dateTime) + : ""; + + const content = ( +
+ + {timeLabel} + +
+

{event.summary}

+ {event.location && ( +
+ + {event.location} +
+ )} +
+
+ ); + + if (event.htmlLink) { + return ( + + {content} + + ); + } + + return content; +} diff --git a/apps/webapp/app/components/daily/daily-widget-grid.client.tsx b/apps/webapp/app/components/daily/daily-widget-grid.client.tsx index 1860d21e4..36a04c511 100644 --- a/apps/webapp/app/components/daily/daily-widget-grid.client.tsx +++ b/apps/webapp/app/components/daily/daily-widget-grid.client.tsx @@ -17,6 +17,7 @@ import { DialogTitle, } from "~/components/ui/dialog"; import { NeedsAttentionWidget } from "./needs-attention-widget.client"; +import { CalendarWidget } from "./calendar-widget.client"; import { getIcon, type IconType } from "~/components/icon-utils"; interface NativeWidget { @@ -31,6 +32,11 @@ const NATIVE_WIDGETS: NativeWidget[] = [ widgetName: "Needs Attention", widgetDescription: "Waiting tasks that need your attention", }, + { + widgetSlug: "calendar", + widgetName: "Today's Events", + widgetDescription: "Google Calendar events for today", + }, ]; const NATIVE_WIDGET_MAP: Record = Object.fromEntries( @@ -307,6 +313,8 @@ export function DailyWidgetGrid({
{isNative && cell.widgetSlug === "needs-attention" ? ( + ) : isNative && cell.widgetSlug === "calendar" ? ( + ) : integrationOption && widgetPat ? ( 1, + }, + async ({ searchParams, authentication }) => { + const { timezone } = searchParams; + const userId = authentication.userId; + const workspaceId = authentication.workspaceId as string; + + const accounts = await IntegrationLoader.getConnectedIntegrationAccounts( + userId, + workspaceId, + ["google-calendar"], + ); + + if (accounts.length === 0) { + return json({ events: [] as CalendarEvent[], connected: false }); + } + + const account = accounts[0]; + const { timeMin, timeMax } = getTodayBounds(timezone); + + let rawEvents: any[] = []; + try { + const result = await executeIntegrationAction( + account.id, + "list_events", + { + calendarId: "primary", + timeMin, + timeMax, + singleEvents: true, + orderBy: "startTime", + maxResults: 50, + }, + userId, + ); + + const text = (result as any)?.content?.[0]?.text; + if (text) { + rawEvents = JSON.parse(text); + } + } catch { + return json({ events: [] as CalendarEvent[], connected: true, error: true }); + } + + const events: CalendarEvent[] = rawEvents.map((e: any) => ({ + id: e.id ?? "", + summary: e.summary ?? "(No title)", + start: e.start ?? {}, + end: e.end ?? {}, + location: e.location, + htmlLink: e.htmlLink, + allDay: !!e.start?.date && !e.start?.dateTime, + })); + + return json({ events, connected: true, error: false }); + }, +); + +export { loader }; diff --git a/integrations/google-calendar/src/mcp/index.ts b/integrations/google-calendar/src/mcp/index.ts index 45c2739b7..b05febf5d 100644 --- a/integrations/google-calendar/src/mcp/index.ts +++ b/integrations/google-calendar/src/mcp/index.ts @@ -345,29 +345,11 @@ export async function callTool( }); const events = response.data.items || []; - if (events.length === 0) { - return { - content: [ - { - type: 'text', - text: 'No events found in the specified time range.', - }, - ], - }; - } - - const eventList = events - .map( - event => - `- ${event.summary} (${event.start?.dateTime || event.start?.date})\n ID: ${event.id}\n Location: ${event.location || 'N/A'}` - ) - .join('\n\n'); - return { content: [ { type: 'text', - text: `Found ${events.length} events:\n\n${eventList}`, + text: JSON.stringify(events), }, ], }; From 5134df3c33637fd444dd7a49c94f02d53407c792 Mon Sep 17 00:00:00 2001 From: Manik Date: Fri, 15 May 2026 12:29:52 +0530 Subject: [PATCH 2/2] fix(calendar): handle missing workspaceId and integration errors without throwing - Guard against undefined workspaceId (possible for JWT/API-key auth) by returning connected:false instead of passing undefined to Prisma which can throw a validation error in strict mode - Wrap getConnectedIntegrationAccounts in try-catch so any DB error returns connected:false rather than propagating a 500 that the widget misreads as connected:true - Short-circuit on result.isError before attempting JSON.parse so explicit integration error responses return the correct error state Fixes: calendar widget shows "Could not load events" instead of "Connect Google Calendar" when calendar is not connected Co-Authored-By: Claude Sonnet 4.6 --- .../routes/api.v1.calendar.today-events.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.calendar.today-events.tsx b/apps/webapp/app/routes/api.v1.calendar.today-events.tsx index c22ea352d..f0f7f7e4d 100644 --- a/apps/webapp/app/routes/api.v1.calendar.today-events.tsx +++ b/apps/webapp/app/routes/api.v1.calendar.today-events.tsx @@ -62,13 +62,22 @@ const loader = createHybridLoaderApiRoute( async ({ searchParams, authentication }) => { const { timezone } = searchParams; const userId = authentication.userId; - const workspaceId = authentication.workspaceId as string; + const workspaceId = authentication.workspaceId; - const accounts = await IntegrationLoader.getConnectedIntegrationAccounts( - userId, - workspaceId, - ["google-calendar"], - ); + if (!workspaceId) { + return json({ events: [] as CalendarEvent[], connected: false }); + } + + let accounts; + try { + accounts = await IntegrationLoader.getConnectedIntegrationAccounts( + userId, + workspaceId, + ["google-calendar"], + ); + } catch { + return json({ events: [] as CalendarEvent[], connected: false }); + } if (accounts.length === 0) { return json({ events: [] as CalendarEvent[], connected: false }); @@ -93,6 +102,10 @@ const loader = createHybridLoaderApiRoute( userId, ); + if ((result as any)?.isError) { + return json({ events: [] as CalendarEvent[], connected: true, error: true }); + } + const text = (result as any)?.content?.[0]?.text; if (text) { rawEvents = JSON.parse(text);