Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions apps/webapp/app/components/daily/calendar-widget.client.tsx
Original file line number Diff line number Diff line change
@@ -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<FetchResult | null>(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 (
<div className="w-full">
{/* Header */}
<div className="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<CalendarDays size={13} className="text-muted-foreground shrink-0" />
<span className="text-muted-foreground flex-1 truncate text-xs font-medium">
Today's Events
</span>
{!loading && (
<button
onClick={fetchEvents}
className="text-muted-foreground hover:text-foreground transition-colors"
title="Refresh"
>
<RefreshCw size={11} />
</button>
)}
</div>

{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-6">
<LoaderCircle size={16} className="text-muted-foreground animate-spin" />
</div>
)}

{/* Not connected */}
{!loading && data && !data.connected && (
<div className="px-3 py-4 text-center">
<p className="text-muted-foreground text-xs">
Connect Google Calendar to see your events.
</p>
<a
href="/home/integrations"
className="text-primary mt-1 inline-block text-xs underline"
>
Connect
</a>
</div>
)}

{/* Error */}
{!loading && data?.connected && data.error && (
<div className="px-3 py-4 text-center">
<p className="text-muted-foreground text-xs">
Could not load events.
</p>
<button
onClick={fetchEvents}
className="text-primary mt-1 text-xs underline"
>
Retry
</button>
</div>
)}

{/* Empty */}
{!loading && data?.connected && !data.error && data.events.length === 0 && (
<div className="flex flex-col items-center gap-1 py-6">
<CalendarDays size={20} className="text-muted-foreground" />
<p className="text-muted-foreground text-xs">No events today</p>
</div>
)}

{/* Event list */}
{!loading && data?.connected && !data.error && data.events.length > 0 && (
<div className="flex flex-col">
{allDayEvents.map((event) => (
<EventRow key={event.id} event={event} />
))}
{timedEvents.map((event) => (
<EventRow key={event.id} event={event} />
))}
</div>
)}
</div>
);
}

function EventRow({ event }: { event: CalendarEvent }) {
const timeLabel = event.allDay
? "All day"
: event.start.dateTime
? formatTime(event.start.dateTime)
: "";

const content = (
<div className="hover:bg-grayAlpha-50 flex items-start gap-2 border-b border-gray-100 px-3 py-2 last:border-0 dark:border-gray-800">
<span
className={
event.allDay
? "mt-0.5 shrink-0 rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-200"
: "text-muted-foreground mt-0.5 w-10 shrink-0 font-mono text-[11px]"
}
>
{timeLabel}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{event.summary}</p>
{event.location && (
<div className="text-muted-foreground mt-0.5 flex items-center gap-1">
<MapPin size={9} className="shrink-0" />
<span className="truncate text-[10px]">{event.location}</span>
</div>
)}
</div>
</div>
);

if (event.htmlLink) {
return (
<a
href={event.htmlLink}
target="_blank"
rel="noreferrer"
className="no-underline"
>
{content}
</a>
);
}

return content;
}
8 changes: 8 additions & 0 deletions apps/webapp/app/components/daily/daily-widget-grid.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, NativeWidget> = Object.fromEntries(
Expand Down Expand Up @@ -307,6 +313,8 @@ export function DailyWidgetGrid({
<div className="w-full">
{isNative && cell.widgetSlug === "needs-attention" ? (
<NeedsAttentionWidget />
) : isNative && cell.widgetSlug === "calendar" ? (
<CalendarWidget />
) : integrationOption && widgetPat ? (
<WidgetCell
widgetSlug={integrationOption.widgetSlug}
Expand Down
131 changes: 131 additions & 0 deletions apps/webapp/app/routes/api.v1.calendar.today-events.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { createHybridLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { IntegrationLoader } from "~/utils/mcp/integration-loader";
import { executeIntegrationAction } from "~/utils/mcp/integration-operations";

const SearchParamsSchema = z.object({
timezone: z.string().optional().default("UTC"),
});

function getTodayBounds(timezone: string): { timeMin: string; timeMax: string } {
const now = new Date();

// Get today's date string in the requested timezone (YYYY-MM-DD)
const dateStr = new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);

// Get the UTC offset for this timezone at the start of today
const startOfDay = new Date(`${dateStr}T00:00:00`);
const endOfDay = new Date(`${dateStr}T23:59:59`);

// Convert local midnight/end-of-day to UTC ISO strings using the timezone offset
const offsetMs = getTimezoneOffsetMs(timezone, startOfDay);
const timeMin = new Date(startOfDay.getTime() - offsetMs).toISOString();
const timeMax = new Date(endOfDay.getTime() - offsetMs).toISOString();

return { timeMin, timeMax };
}

function getTimezoneOffsetMs(timezone: string, date: Date): number {
// Compute the UTC offset in milliseconds for a given timezone at a given date
const utcDate = new Date(
date.toLocaleString("en-US", { timeZone: "UTC" }),
);
const localDate = new Date(
date.toLocaleString("en-US", { timeZone: timezone }),
);
return utcDate.getTime() - localDate.getTime();
}

export interface CalendarEvent {
id: string;
summary: string;
start: { dateTime?: string; date?: string; timeZone?: string };
end: { dateTime?: string; date?: string; timeZone?: string };
location?: string;
htmlLink?: string;
allDay: boolean;
}

const loader = createHybridLoaderApiRoute(
{
searchParams: SearchParamsSchema,
allowJWT: true,
corsStrategy: "all",
findResource: async () => 1,
},
async ({ searchParams, authentication }) => {
const { timezone } = searchParams;
const userId = authentication.userId;
const workspaceId = authentication.workspaceId;

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 });
}

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,
);

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);
}
} 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 };
20 changes: 1 addition & 19 deletions integrations/google-calendar/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
],
};
Expand Down