import type { ModuleManifest, WidgetContext } from "../_core/module"; import { z } from "zod"; import { listCalendars, listEvents, searchCalendars, searchEvents } from "./server/queries"; import { canShareCalendar, canShareEvent, loadCalendarForShare, loadEventForShare, type CalendarShareData, type EventShareData, } from "./server/share-queries"; import { CalendarSharedView, EventSharedView } from "./components/shared-view"; const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]); const upcomingConfigSchema = z.object({ calendarIds: calendarIdsSchema, days: z.number().int().min(1).max(30), }); const monthConfigSchema = z.object({ calendarIds: calendarIdsSchema }); function dayLabel(d: Date): string { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const target = new Date(d); target.setHours(0, 0, 0, 0); if (target.getTime() === today.getTime()) return "Today"; if (target.getTime() === tomorrow.getTime()) return "Tomorrow"; return d.toLocaleDateString(undefined, { weekday: "long" }); } function formatTime(d: Date): string { return d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); } async function UpcomingEventsWidget({ config }: { config: unknown; ctx: WidgetContext }) { const parsed = upcomingConfigSchema.parse(config); const now = new Date(); const end = new Date(now.getTime() + parsed.days * 24 * 60 * 60 * 1000); const events = await listEvents({ from: now, to: end, calendarIds: parsed.calendarIds }); if (events.length === 0) { return (

No events in the next {parsed.days} day{parsed.days !== 1 ? "s" : ""}

); } // Group by day const groups = new Map(); for (const e of events) { const start = new Date(e.startAt); const key = start.toDateString(); if (!groups.has(key)) groups.set(key, { day: start, events: [] }); groups.get(key)!.events.push(e); } return (
{[...groups.values()].slice(0, 4).map((group, gi) => (
{dayLabel(group.day)} {group.day.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
{group.events.map((event) => { const start = new Date(event.startAt); return (
{event.allDay ? "all day" : formatTime(start)}
{event.title}
{event.location && (
{event.location}
)}
); })}
))}
); } async function MonthWidget({ config }: { config: unknown; ctx: WidgetContext }) { const parsed = monthConfigSchema.parse(config); const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); const events = await listEvents({ from: monthStart, to: monthEnd, calendarIds: parsed.calendarIds, }); const monthName = now.toLocaleDateString(undefined, { month: "long", year: "numeric" }); return (
{monthName}
{events.length === 0 ? (

No events this month

) : (
{events.slice(0, 10).map((event) => { const eventStart = new Date(event.startAt); const day = eventStart.getDate(); return (
{day} {event.title}
); })} {events.length > 10 && (
+{events.length - 10} more
)}
)}
); } const manifest: ModuleManifest = { id: "calendar", name: "Calendar", nav: { href: "/calendar", label: "Calendar", icon: "calendar" }, entities: [ { type: "calendar.calendar", label: { singular: "Calendar", plural: "Calendars" }, share: { canShare: true, defaultCapabilities: ["read"] }, search: { search: searchCalendars }, resolveUrl: (id) => `/calendar?id=${id}`, canShareEntity: canShareCalendar, loadForShare: loadCalendarForShare, renderSharedView: ({ data }) => , renderActivity: (entry) => { const name = entry.payload?.name as string | undefined; if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`; if (entry.action === "delete") return "Deleted calendar"; return `Updated calendar${name ? ` "${name}"` : ""}`; }, }, { type: "calendar.event", label: { singular: "Event", plural: "Events" }, share: { canShare: true, defaultCapabilities: ["read"] }, reminder: { canRemind: true }, search: { search: searchEvents }, resolveUrl: (id) => `/calendar/events/${id}`, canShareEntity: canShareEvent, loadForShare: loadEventForShare, renderSharedView: ({ data }) => , renderActivity: (entry) => { const title = entry.payload?.title as string | undefined; if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`; if (entry.action === "delete") return "Deleted event"; return `Updated event${title ? ` "${title}"` : ""}`; }, }, ], dashboardWidgets: [ { id: "calendar.upcoming", title: "Upcoming events", description: "Events from selected calendars over the next few days.", category: "Calendar", defaultSize: { w: 4, h: 3 }, minSize: { w: 3, h: 2 }, defaultPriority: 10, configSchema: upcomingConfigSchema, defaultConfig: { calendarIds: "all", days: 3 }, resolveConfigOptions: async () => ({ calendars: (await listCalendars()).map((calendar) => ({ id: calendar.id, name: calendar.name, visibility: calendar.visibility, })), }), render: (props) => , }, { id: "calendar.month", title: "Month calendar", description: "A compact month view for selected calendars.", category: "Calendar", defaultSize: { w: 6, h: 5 }, minSize: { w: 4, h: 4 }, defaultPriority: 20, configSchema: monthConfigSchema, defaultConfig: { calendarIds: "all" }, resolveConfigOptions: async () => ({ calendars: (await listCalendars()).map((calendar) => ({ id: calendar.id, name: calendar.name, visibility: calendar.visibility, })), }), render: (props) => , }, ], quickAdds: [ { id: "calendar.new-event", label: "New event", icon: "calendar-plus", url: "/calendar", createKey: "calendar.event", }, { id: "calendar.new-calendar", label: "New calendar", icon: "calendar-days", url: "/calendar", createKey: "calendar.calendar", }, ], }; export default manifest;