/// /** * Imported Calendar Pattern * * A weekly calendar view that displays events from Google Calendar Importer * directly via wish(). Also supports creating and managing local events. * * Features: * - Display Google Calendar events (read-only, links to Google) * - Create local events with drag-to-move and resize * - Edit local events via modal * - Day/Week view toggle */ import { action, Cell, computed, Default, derive, handler, ifElse, NAME, pattern, UI, wish, Writable, } from "commontools"; // Type matching CalendarEvent from google-calendar-importer.tsx type CalendarEvent = { id: string; summary: string; description: string; location: string; start: string; end: string; startDateTime: string; endDateTime: string; isAllDay: boolean; status: string; htmlLink: string; calendarId: string; calendarName: string; attendees: { email: string; displayName: string; responseStatus: string }[]; organizer: { email: string; displayName: string }; }; // Type for user-created local events type LocalEvent = { eventId: string; title: string; date: string; // YYYY-MM-DD startTime: string; // HH:MM endTime: string; // HH:MM color: string; notes: string; }; interface Input { title?: Default; localEvents?: Writable>; } interface Output { title: string; eventCount: number; localEvents: LocalEvent[]; } // ============ CONSTANTS ============ const HOUR_HEIGHT = 60; const DAY_START = 6; const DAY_END = 22; const RESIZE_HANDLE_HEIGHT = 14; const SLOT_HEIGHT = HOUR_HEIGHT / 2; const COLORS = [ "#93c5fd", // blue "#86efac", // green "#fca5a5", // red "#fcd34d", // yellow "#c4b5fd", // purple "#fdba74", // orange "#67e8f9", // cyan "#f9a8d4", // pink ]; // Simple random ID generator const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; // ============ STYLES ============ const STYLES = { button: { base: { padding: "4px 8px", fontSize: "0.75rem", border: "1px solid #d1d5db", borderRadius: "4px", backgroundColor: "#fff", cursor: "pointer", }, primary: { padding: "4px 12px", fontSize: "0.75rem", border: "none", borderRadius: "4px", backgroundColor: "#3b82f6", color: "#fff", cursor: "pointer", }, danger: { padding: "6px 12px", fontSize: "0.75rem", border: "none", borderRadius: "4px", backgroundColor: "#fee2e2", color: "#dc2626", cursor: "pointer", }, }, label: { fontSize: "0.75rem", fontWeight: "500", display: "block", marginBottom: "4px", }, colorSwatch: { width: "24px", height: "24px", borderRadius: "4px", cursor: "pointer", }, } as const; // ============ DATE HELPERS ============ const formatDatePST = (d: Date): string => d.toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" }); const getTodayDate = (): string => formatDatePST(new Date()); const getWeekStart = (date: string): string => { const d = new Date(date + "T12:00:00-08:00"); const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1); d.setDate(diff); return formatDatePST(d); }; const addDays = (date: string, days: number): string => { const d = new Date(date + "T12:00:00-08:00"); d.setDate(d.getDate() + days); return formatDatePST(d); }; const getWeekDates = (start: string, count: number): string[] => { const dates: string[] = []; const d = new Date(start + "T12:00:00-08:00"); for (let i = 0; i < count; i++) { const nd = new Date(d); nd.setDate(d.getDate() + i); dates.push(formatDatePST(nd)); } return dates; }; const formatDateHeader = (date: string): string => { const d = new Date(date + "T12:00:00-08:00"); return d.toLocaleDateString("en-US", { timeZone: "America/Los_Angeles", weekday: "short", month: "short", day: "numeric", }); }; // ============ TIME HELPERS ============ const timeToMinutes = (time: string): number => { if (!time) return 0; const [h, m] = time.split(":").map(Number); return h * 60 + (m || 0); }; const minutesToTime = (minutes: number): string => { const h = Math.min(23, Math.max(0, Math.floor(minutes / 60))); const m = Math.max(0, minutes % 60); return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; }; const addMinutesToTime = (time: string, minutes: number): string => minutesToTime(timeToMinutes(time) + minutes); const addHoursToTime = (time: string, hours: number): string => addMinutesToTime(time, hours * 60); // Extract time from ISO datetime const extractTime = (isoDateTime: string): string => { if (!isoDateTime) return "09:00"; const match = isoDateTime.match(/T(\d{2}):(\d{2})/); if (match) { return `${match[1]}:${match[2]}`; } return "09:00"; }; // Extract date from ISO datetime or date string const extractDate = (dateOrDateTime: string): string => { if (!dateOrDateTime) return ""; if (/^\d{4}-\d{2}-\d{2}$/.test(dateOrDateTime)) { return dateOrDateTime; } const match = dateOrDateTime.match(/^(\d{4}-\d{2}-\d{2})/); return match ? match[1] : ""; }; // ============ HOUR DATA ============ const buildHours = (): Array<{ idx: number; label: string }> => { const hours: Array<{ idx: number; label: string }> = []; for (let h = DAY_START; h < DAY_END; h++) { const period = h >= 12 ? "PM" : "AM"; const hour = h % 12 || 12; hours.push({ idx: h - DAY_START, label: `${hour} ${period}` }); } return hours; }; const HOURS = buildHours(); const GRID_HEIGHT = (DAY_END - DAY_START) * HOUR_HEIGHT; const COLUMN_INDICES = [0, 1, 2, 3, 4, 5, 6] as const; // Color assignment for calendars let globalColorIndex = 0; const calendarColorMap = new Map(); function getColorForCalendar(calendarId: string): string { if (!calendarColorMap.has(calendarId)) { calendarColorMap.set(calendarId, COLORS[globalColorIndex % COLORS.length]); globalColorIndex++; } return calendarColorMap.get(calendarId)!; } // ============ MODULE-SCOPE HANDLERS ============ // Handler to show the new event modal const showNewEventModal = handler< void, { showNewEventPrompt: Writable } >((_, { showNewEventPrompt }) => showNewEventPrompt.set(true)); // Handler to create event and close modal const createEventHandler = handler< void, { newEventTitle: Writable; newEventDate: Writable; newEventStartTime: Writable; newEventEndTime: Writable; newEventColor: Writable; showNewEventPrompt: Writable; localEvents: Writable; } >(( _, { newEventTitle, newEventDate, newEventStartTime, newEventEndTime, newEventColor, showNewEventPrompt, localEvents, }, ) => { const title = newEventTitle.get() || "New Event"; const newEvent: LocalEvent = { eventId: generateId(), title, date: newEventDate.get(), startTime: newEventStartTime.get(), endTime: newEventEndTime.get(), color: newEventColor.get(), notes: "", }; localEvents.push(newEvent); // Reset modal state showNewEventPrompt.set(false); newEventTitle.set(""); }); // Handler to create event and stay in modal const createEventAndContinue = handler< void, { newEventTitle: Writable; newEventDate: Writable; newEventStartTime: Writable; newEventEndTime: Writable; newEventColor: Writable; localEvents: Writable; usedCreateAnother: Writable; } >(( _, { newEventTitle, newEventDate, newEventStartTime, newEventEndTime, newEventColor, localEvents, usedCreateAnother, }, ) => { const title = newEventTitle.get() || "New Event"; const newEvent: LocalEvent = { eventId: generateId(), title, date: newEventDate.get(), startTime: newEventStartTime.get(), endTime: newEventEndTime.get(), color: newEventColor.get(), notes: "", }; localEvents.push(newEvent); usedCreateAnother.set(true); newEventTitle.set(""); }); // Handler to cancel new event prompt const cancelNewEventPrompt = handler< void, { showNewEventPrompt: Writable; newEventTitle: Writable; usedCreateAnother: Writable; } >((_, { showNewEventPrompt, newEventTitle, usedCreateAnother }) => { showNewEventPrompt.set(false); newEventTitle.set(""); usedCreateAnother.set(false); }); // ============ PATTERN ============ const ImportedCalendar = pattern(({ title, localEvents }) => { // ========================================================================== // WISH FOR CALENDAR EVENTS from Google Calendar Importer // ========================================================================== const { events: importedEvents } = wish<{ events: CalendarEvent[] }>( { query: "#calendarEvents" }, ).result; // Navigation State (Writable so navigation buttons work) const startDate = Writable.of(getWeekStart(getTodayDate())); const visibleDays = Writable.of(7); // Create Form State const showNewEventPrompt = Writable.of(false); const newEventTitle = Writable.of(""); const newEventDate = Writable.of(getTodayDate()); const newEventStartTime = Writable.of("09:00"); const newEventEndTime = Writable.of("10:00"); const newEventColor = Writable.of(COLORS[0]); const usedCreateAnother = Writable.of(false); // Edit Form State const showEditModal = Writable.of(false); const editingEventIndex = Writable.of(-1); const editEventTitle = Writable.of(""); const editEventDate = Writable.of(""); const editEventStartTime = Writable.of("09:00"); const editEventEndTime = Writable.of("10:00"); const editEventColor = Writable.of(COLORS[0]); // Track last drop time to prevent click firing after drag const lastDropTime = Cell.of(0); // Computed Values const importedEventCount = derive( importedEvents, (evts: CalendarEvent[]) => evts?.length || 0, ); const localEventCount = computed(() => localEvents.get().length); const eventCount = computed(() => importedEventCount + localEventCount); const weekDates = computed(() => getWeekDates(startDate.get(), 7)); const todayDate = getTodayDate(); // Navigation Actions const goPrev = action(() => { startDate.set(addDays(startDate.get(), -visibleDays.get())); }); const goNext = action(() => { startDate.set(addDays(startDate.get(), visibleDays.get())); }); const goToday = action(() => { const today = getTodayDate(); startDate.set(visibleDays.get() === 1 ? today : getWeekStart(today)); }); // View Mode Actions const setDayView = action(() => visibleDays.set(1)); const setWeekView = action(() => visibleDays.set(7)); // Form helpers const onStartTimeChange = action((e: { detail: { value: string } }) => { const newStart = e?.detail?.value; if (newStart) { newEventEndTime.set(addHoursToTime(newStart, 1)); } }); // Color selection actions (for create modal) const colorActions = COLORS.map((color) => action(() => newEventColor.set(color)) ); // Color selection actions (for edit modal) const editColorActions = COLORS.map((color) => action(() => editEventColor.set(color)) ); // Edit form helpers const onEditStartTimeChange = action((e: { detail: { value: string } }) => { const newStart = e?.detail?.value; if (newStart) { editEventEndTime.set(addHoursToTime(newStart, 1)); } }); // Close edit modal const closeEditModal = action(() => { showEditModal.set(false); editingEventIndex.set(-1); }); // Save edited event const saveEditedEvent = action(() => { const idx = editingEventIndex.get(); if (idx < 0) return; const eventCell = localEvents.key(idx); eventCell.key("title").set(editEventTitle.get()); eventCell.key("date").set(editEventDate.get()); eventCell.key("startTime").set(editEventStartTime.get()); eventCell.key("endTime").set(editEventEndTime.get()); eventCell.key("color").set(editEventColor.get()); showEditModal.set(false); editingEventIndex.set(-1); }); // Delete event const deleteEvent = action(() => { const idx = editingEventIndex.get(); if (idx < 0) return; const currentEvents = localEvents.get(); const updated = [...currentEvents]; updated.splice(idx, 1); localEvents.set(updated); showEditModal.set(false); editingEventIndex.set(-1); }); // Computed Styles for View Toggle const dayButtonStyle = computed(() => ({ ...STYLES.button.base, backgroundColor: visibleDays.get() === 1 ? "#3b82f6" : "#fff", color: visibleDays.get() === 1 ? "#fff" : "#374151", })); const weekButtonStyle = computed(() => ({ ...STYLES.button.base, backgroundColor: visibleDays.get() === 7 ? "#3b82f6" : "#fff", color: visibleDays.get() === 7 ? "#fff" : "#374151", })); // ===== Render ===== return { [NAME]: computed(() => `${title} (${eventCount})`), [UI]: ( {/* Header */}
{title} ({eventCount} events)
{/* View Mode Buttons */}
{/* Navigation Buttons */}
{/* Add Button */}
{/* Main Calendar Area */}
{/* New Event Modal */} New Event
{/* Title Input */}
{/* Date Input */}
{/* Time Inputs */}
{/* Color Picker */}
{COLORS.map((c, idx) => (
newEventColor.get() === c ? "2px solid #111" : "2px solid transparent" ), }} onClick={colorActions[idx]} /> ))}
{/* Modal Footer */}
{/* Edit Event Modal */} Edit Event
{/* Title Input */}
{/* Date Input */}
{/* Time Inputs */}
{/* Color Picker */}
{COLORS.map((c, idx) => (
editEventColor.get() === c ? "2px solid #111" : "2px solid transparent" ), }} onClick={editColorActions[idx]} /> ))}
{/* Modal Footer */}
{/* Calendar Grid */}
{/* Time Labels Column */}
{HOURS.map((hour) => (
{hour.label}
))}
{/* Day Columns */} {COLUMN_INDICES.map((colIdx) => { // Use computed() to properly extract values from the computed array const columnDate = computed(() => weekDates[colIdx] || ""); const isToday = derive(weekDates, (dates) => dates?.[colIdx] === todayDate); const dateHeader = derive(weekDates, (dates) => { const d = dates?.[colIdx]; return d ? formatDateHeader(d) : ""; }); const displayStyle = computed(() => colIdx < visibleDays.get() ? "flex" : "none" ); const headerBg = derive(weekDates, (dates) => dates?.[colIdx] === todayDate ? "#eff6ff" : "transparent"); const headerColor = derive(weekDates, (dates) => dates?.[colIdx] === todayDate ? "#2563eb" : "#374151"); // Drop handler for moving/resizing local events const handleDayDrop = action((e: { detail: { sourceCell: Cell; pointerY?: number; dropZoneRect?: { top: number }; type?: string; }; }) => { const evt = e.detail.sourceCell.get(); const { pointerY, dropZoneRect, type: dragType } = e.detail; if (pointerY === undefined || !dropZoneRect) { return; } const relativeY = pointerY - dropZoneRect.top; const slotIdx = Math.max( 0, Math.floor(relativeY / SLOT_HEIGHT), ); const newHour = DAY_START + Math.floor(slotIdx / 2); const newMin = (slotIdx % 2) * 30; const newTime = minutesToTime( Math.min(DAY_END - 1, Math.max(DAY_START, newHour)) * 60 + newMin, ); const current = localEvents.get(); const evtId = evt?.eventId; const evtIdx = current.findIndex((a) => a?.eventId === evtId ); if (evtIdx < 0) { return; } const dateVal = weekDates[colIdx]; const eventCell = localEvents.key(evtIdx); if (dragType === "local-event-resize") { const adjustedY = relativeY + SLOT_HEIGHT / 2; const resizeSlotIdx = Math.max( 0, Math.floor(adjustedY / SLOT_HEIGHT), ); const resizeHour = DAY_START + Math.floor(resizeSlotIdx / 2); const resizeMin = (resizeSlotIdx % 2) * 30; const startMin = timeToMinutes(evt.startTime || "09:00"); const newEndMin = Math.max( startMin + 30, resizeHour * 60 + resizeMin, ); eventCell.key("endTime").set( minutesToTime(Math.min(DAY_END * 60, newEndMin)), ); } else { const duration = timeToMinutes(evt.endTime || "10:00") - timeToMinutes(evt.startTime || "09:00"); eventCell.key("date").set(dateVal); eventCell.key("startTime").set(newTime); eventCell.key("endTime").set( addMinutesToTime(newTime, duration), ); } lastDropTime.set(Date.now()); }); // Click handlers for creating events at specific hours const hourClickActions = HOURS.map((hour) => action(() => { if (Date.now() - lastDropTime.get() < 300) { return; } newEventTitle.set(""); newEventDate.set(columnDate); newEventStartTime.set( `${ (hour.idx + DAY_START).toString().padStart(2, "0") }:00`, ); newEventEndTime.set( addHoursToTime( `${ (hour.idx + DAY_START).toString().padStart(2, "0") }:00`, 1, ), ); newEventColor.set(COLORS[0]); showNewEventPrompt.set(true); }) ); return (
{/* Date Header */}
{dateHeader}
{ifElse( isToday,
Today
, null, )}
{/* Time Grid with Drop Zone */} {HOURS.map((hour, hourIdx) => (
))}
); })} {/* Event Blocks - using derive() to compute everything at once */} {importedEvents.map((evt) => { // Use derive() to extract display properties // All derives guard against undefined events const evtTitle = derive( evt, (e) => e?.summary || "(No title)", ); const evtColor = derive( evt, (e) => getColorForCalendar(e?.calendarId || "default"), ); const evtLocation = derive(evt, (e) => e?.location || ""); const evtTimeRange = derive(evt, (e) => { if (!e) { return ""; } const startTime = e.isAllDay ? "00:00" : extractTime(e.startDateTime); const endTime = e.isAllDay ? "23:59" : extractTime(e.endDateTime); return `${startTime} - ${endTime}`; }); const hasLocation = derive( evt, (e) => (e?.location || "").length > 0, ); // Build direct event edit link: /r/eventedit/{base64(eventId + " " + calendarId)} // This loads faster than the full calendar view const googleLink = derive(evt, (e) => { if (!e) { return ""; } if (!e.id || !e.calendarId) { return e.htmlLink || ""; } try { const combined = `${e.id} ${e.calendarId}`; const encoded = btoa(combined); return `https://calendar.google.com/calendar/u/0/r/eventedit/${encoded}`; } catch { // Fallback to htmlLink if encoding fails return e.htmlLink || ""; } }); // Compute position/visibility in single derive // Note: startDate and visibleDays are Cell.of(), so access with .get() const styles = derive(evt, (e) => { const weekStart = startDate.get(); const visibleCount = visibleDays.get(); const eventDate = e ? extractDate(e.start || e.startDateTime) : null; const hidden = { top: "0", height: "0", left: "0", width: "0", display: "none" as const, }; if (!eventDate || !weekStart) { return hidden; } const startMs = new Date(weekStart + "T00:00:00").getTime(); const evtMs = new Date(eventDate + "T00:00:00").getTime(); if (isNaN(startMs) || isNaN(evtMs)) { return hidden; } const dayOffset = Math.floor( (evtMs - startMs) / (24 * 60 * 60 * 1000), ); if (dayOffset < 0 || dayOffset >= visibleCount) { return hidden; } const startTime = e.isAllDay ? "00:00" : extractTime(e.startDateTime); const endTime = e.isAllDay ? "23:59" : extractTime(e.endDateTime); const startMin = timeToMinutes(startTime) - DAY_START * 60; const endMin = timeToMinutes(endTime) - DAY_START * 60; const top = (startMin / 60) * HOUR_HEIGHT; const height = Math.max( 30, ((endMin - startMin) / 60) * HOUR_HEIGHT, ); return { top: `${50 + top}px`, height: `${height}px`, left: `calc(50px + (100% - 50px) * ${dayOffset} / ${visibleCount} + 2px)`, width: `calc((100% - 50px) / ${visibleCount} - 4px)`, display: "block" as const, }; }); return ( {/* Title */}
{evtTitle}
{/* Time & Location */}
{evtTimeRange} {ifElse( hasLocation,
📍 {evtLocation}
, null, )}
); })} {/* Local Event Blocks - with drag/drop support */} {localEvents.map((evt, evtIndex) => { // Compute position and visibility const styles = computed(() => { const weekStart = startDate.get(); const visibleCount = visibleDays.get(); const evtDate = evt.date; const hidden = { top: "0", height: "0", left: "0", width: "0", display: "none" as const, }; if (!evtDate || !weekStart) { return hidden; } const startMs = new Date(weekStart + "T00:00:00").getTime(); const evtMs = new Date(evtDate + "T00:00:00").getTime(); if (isNaN(startMs) || isNaN(evtMs)) { return hidden; } const dayOffset = Math.floor( (evtMs - startMs) / (24 * 60 * 60 * 1000), ); if (dayOffset < 0 || dayOffset >= visibleCount) { return hidden; } const startMin = timeToMinutes(evt.startTime || "09:00") - DAY_START * 60; const endMin = timeToMinutes(evt.endTime || "10:00") - DAY_START * 60; const top = (startMin / 60) * HOUR_HEIGHT; const height = Math.max( 30, ((endMin - startMin) / 60) * HOUR_HEIGHT, ); return { top: `${50 + top}px`, height: `${height}px`, left: `calc(50px + (100% - 50px) * ${dayOffset} / ${visibleCount} + 2px)`, width: `calc((100% - 50px) / ${visibleCount} - 4px)`, display: "block" as const, }; }); // Click action to open edit modal const openEvent = action(() => { if (Date.now() - lastDropTime.get() < 300) { return; } // Populate edit form with event data editingEventIndex.set(evtIndex); editEventTitle.set(evt.title || ""); editEventDate.set(evt.date || ""); editEventStartTime.set(evt.startTime || "09:00"); editEventEndTime.set(evt.endTime || "10:00"); editEventColor.set(evt.color || COLORS[0]); showEditModal.set(true); }); // Workaround: Use computed() with evt.eventId dependency for static children const resizeHandleLines = computed(() => { const _id = evt.eventId; return (
); }); const dragAreaContent = computed(() => { const _id = evt.eventId; return (
); }); return (
{/* Title */}
{evt.title || "(untitled)"}
{/* Drag Source for Moving */} {dragAreaContent} {/* Resize Drag Source */} {resizeHandleLines}
); })}
{/* Empty State */} {ifElse( computed(() => eventCount === 0 ),
No events yet. Click "+ Add" or click on a time slot to create one.
, null, )} ), title, eventCount, localEvents, }; }); export default ImportedCalendar;