/// /** * Weekly Calendar Pattern * * A weekly calendar that manages Event patterns (event.tsx) similar to how * notebook.tsx manages note.tsx patterns. Events are independent pieces that * can be viewed, edited, and created from this calendar. * * Features: * - Day/Week view toggle * - Drag to move events between days/times * - Drag to resize event duration * - Click time slots to create new events * - Color-coded events * - Events are separate pieces that can be opened and edited */ import { action, Cell, computed, Default, handler, ifElse, NAME, navigateTo, pattern, Stream, UI, wish, Writable, } from "commontools"; import Event, { COLORS, generateId } from "./event.tsx"; // ============ TYPES ============ type EventPiece = { [NAME]?: string; title?: string; date?: string; startTime?: string; endTime?: string; color?: string; notes?: string; isHidden?: boolean; eventId?: string; }; type MinimalPiece = { [NAME]?: string; }; // Type for backlinks type MentionablePiece = { [NAME]?: string; isHidden?: boolean; mentioned: MentionablePiece[]; backlinks: MentionablePiece[]; }; interface Input { title?: Default; events: Writable>; isCalendar?: Default; // Marker for identification isHidden?: Default; } interface Output { title: string; events: EventPiece[]; mentionable: EventPiece[]; eventCount: number; summary: string; isCalendar: boolean; isHidden: boolean; backlinks: MentionablePiece[]; // LLM-callable streams createEvent: Stream<{ title: string; date: string; startTime: string; endTime: string; }>; setTitle: Stream<{ newTitle: string }>; } // ============ CONSTANTS ============ const HOUR_HEIGHT = 60; const DAY_START = 6; const DAY_END = 22; const RESIZE_HANDLE_HEIGHT = 14; const SLOT_HEIGHT = HOUR_HEIGHT / 2; // ============ 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); // ============ HOUR DATA (Static - computed once) ============ const buildHours = (): Array< { idx: number; label: string; startTime: string } > => { const hours: Array<{ idx: number; label: string; startTime: string }> = []; for (let h = DAY_START; h < DAY_END; h++) { const period = h >= 12 ? "PM" : "AM"; const hour = h % 12 || 12; const startTime = `${h.toString().padStart(2, "0")}:00`; hours.push({ idx: h - DAY_START, label: `${hour} ${period}`, startTime }); } 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; // ============ MODULE-SCOPE HANDLERS (for LLM-callable streams) ============ // Handler to show the new event modal const showNewEventModal = handler< void, { showNewEventPrompt: Writable } >((_, { showNewEventPrompt }) => showNewEventPrompt.set(true)); // Handler to create event and close modal (stays on calendar) const createEventHandler = handler< void, { newEventTitle: Writable; newEventDate: Writable; newEventStartTime: Writable; newEventEndTime: Writable; newEventColor: Writable; showNewEventPrompt: Writable; events: Writable; allPieces: Writable; } >(( _, { newEventTitle, newEventDate, newEventStartTime, newEventEndTime, newEventColor, showNewEventPrompt, events, allPieces, }, ) => { const title = newEventTitle.get() || "New Event"; const newEvent = Event({ title, date: newEventDate.get(), startTime: newEventStartTime.get(), endTime: newEventEndTime.get(), color: newEventColor.get(), notes: "", isHidden: false, eventId: generateId(), }); allPieces.push(newEvent); events.push(newEvent); // Reset modal state and stay on calendar 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; events: Writable; allPieces: Writable; usedCreateAnother: Writable; } >(( _, { newEventTitle, newEventDate, newEventStartTime, newEventEndTime, newEventColor, events, allPieces, usedCreateAnother, }, ) => { const title = newEventTitle.get() || "New Event"; const newEvent = Event({ title, date: newEventDate.get(), startTime: newEventStartTime.get(), endTime: newEventEndTime.get(), color: newEventColor.get(), notes: "", isHidden: false, eventId: generateId(), }); allPieces.push(newEvent); events.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); }); // Handler for clicking on a backlink const handleBacklinkClick = handler< void, { piece: Writable } >((_, { piece }) => navigateTo(piece)); // LLM-callable handler: Create a single event const handleCreateEvent = handler< { title: string; date: string; startTime: string; endTime: string }, { events: Writable; allPieces: Writable } >(({ title, date, startTime, endTime }, { events, allPieces }) => { const newEvent = Event({ title, date, startTime, endTime, color: COLORS[0], notes: "", isHidden: false, eventId: generateId(), }); allPieces.push(newEvent); events.push(newEvent); return newEvent; }); // LLM-callable handler: Rename the calendar const handleSetTitle = handler< { newTitle: string }, { title: Writable } >(({ newTitle }, { title }) => { title.set(newTitle); return newTitle; }); // ============ PATTERN ============ const WeeklyCalendar = pattern( ({ title, events, isCalendar, isHidden }) => { const { allPieces } = wish<{ allPieces: EventPiece[] }>({ query: "#default" }).result; // Navigation State const startDate = Cell.of(getWeekStart(getTodayDate())); const visibleDays = Cell.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); // Backlinks const backlinks = Writable.of([]); // Computed Values const eventCount = computed(() => events.get().length); const summary = computed(() => { return events.get() .map((e) => `${e.date || ""} ${e.title || ""}`.trim()) .join(", "); }); const weekDates = computed(() => getWeekDates(startDate.get(), 7)); const todayDate = getTodayDate(); // Navigation Actions (using action for internal logic) 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 = events.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 = events.get(); const updated = [...currentEvents]; updated.splice(idx, 1); events.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 */} Day Week {/* Navigation Buttons */} < Today > {/* Add Button */} + Add {/* Main Calendar Area */} {/* New Event Modal */} New Event {/* Title Input */} Title {/* Date Input */} Date {/* Time Inputs */} Start End {/* Color Picker */} Color {COLORS.map((c, idx) => ( newEventColor.get() === c ? "2px solid #111" : "2px solid transparent" ), }} onClick={colorActions[idx]} /> ))} {/* Modal Footer */} Cancel Create Another Create {/* Edit Event Modal */} Edit Event {/* Title Input */} Title {/* Date Input */} Date {/* Time Inputs */} Start End {/* Color Picker */} Color {COLORS.map((c, idx) => ( editEventColor.get() === c ? "2px solid #111" : "2px solid transparent" ), }} onClick={editColorActions[idx]} /> ))} {/* Modal Footer */} Delete Cancel Save {/* Calendar Grid */} {/* Time Labels Column */} {HOURS.map((hour) => ( {hour.label} ))} {/* Day Columns */} {COLUMN_INDICES.map((colIdx) => { // Computed values for this column const columnDate = computed(() => weekDates[colIdx] || ""); const isToday = computed(() => weekDates[colIdx] === todayDate ); const dateHeader = computed(() => { const d = weekDates[colIdx]; return d ? formatDateHeader(d) : ""; }); const displayStyle = computed(() => colIdx < visibleDays.get() ? "flex" : "none" ); const headerBg = computed(() => weekDates[colIdx] === todayDate ? "#eff6ff" : "transparent" ); const headerColor = computed(() => weekDates[colIdx] === todayDate ? "#2563eb" : "#374151" ); // Drop handler for moving/resizing events (using action) 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 = events.get(); const evtId = (evt as any)?.eventId; const evtIdx = current.findIndex( (a: any) => a?.eventId === evtId, ); if (evtIdx < 0) return; const dateVal = weekDates[colIdx]; const eventCell = events.key(evtIdx); if (dragType === "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 (using action) const hourClickActions = HOURS.map((hour) => action(() => { if (Date.now() - lastDropTime.get() < 300) return; newEventTitle.set(""); newEventDate.set(columnDate); newEventStartTime.set(hour.startTime); newEventEndTime.set(addHoursToTime(hour.startTime, 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 */} {events.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(() => events.get().length === 0), No events yet. Click "+ Add" or click on a time slot. , null, )} {/* Backlinks footer */} backlinks.get().length > 0 ? "flex" : "none" ), alignItems: "center", borderTop: "1px solid var(--ct-color-border, #e5e5e7)", flexWrap: "wrap", }} > Linked from: {backlinks.map((piece) => ( {piece?.[NAME]} ))} ), title, events, mentionable: events, eventCount, summary, isCalendar, isHidden, backlinks, // LLM-callable streams createEvent: handleCreateEvent({ events, allPieces }), setTitle: handleSetTitle({ title }), }; }, ); export default WeeklyCalendar;