/// /** * Event Pattern * * An atomic pattern representing a single calendar event. * Can be created, edited, and managed by calendar patterns (like weekly-calendar-turtles). * Similar to how note.tsx is managed by notebook.tsx. */ import { computed, type Default, handler, NAME, navigateTo, pattern, Stream, UI, Writable, } from "commontools"; // Simple random ID generator const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; // Available colors for events const COLORS: string[] = [ "#fef08a", // yellow "#bbf7d0", // green "#bfdbfe", // blue "#fbcfe8", // pink "#fed7aa", // orange "#ddd6fe", // purple ]; // Type for backlinks (inline to work around CLI path resolution bug) type MentionablePiece = { [NAME]?: string; isHidden?: boolean; mentioned: MentionablePiece[]; backlinks: MentionablePiece[]; }; interface Input { title?: Writable>; date?: Writable>; // YYYY-MM-DD startTime?: Writable>; // HH:MM endTime?: Writable>; // HH:MM color?: Writable>; notes?: Writable>; isHidden?: Default; eventId?: Default; } /** Represents a calendar event with a date, time, and notes. */ interface Output { title: string; date: string; startTime: string; endTime: string; color: string; notes: string; summary: string; isHidden: boolean; eventId: string; backlinks: MentionablePiece[]; // LLM-callable streams setTitle: Stream<{ newTitle: string }>; setDate: Stream<{ newDate: string }>; setTime: Stream<{ newStartTime: string; newEndTime: string }>; setColor: Stream<{ newColor: string }>; setNotes: Stream<{ newNotes: string }>; } // ============ STYLES ============ const STYLES = { label: { fontSize: "0.75rem", fontWeight: "500", display: "block", marginBottom: "4px", }, colorSwatch: { width: "24px", height: "24px", borderRadius: "4px", cursor: "pointer", }, } as const; // ============ TIME HELPERS ============ const timeToMinutes = (time: string): number => { if (!time) return 0; const [h, m] = time.split(":").map(Number); return h * 60 + (m || 0); }; const formatDuration = (start: string, end: string): string => { const startMin = timeToMinutes(start); const endMin = timeToMinutes(end); const durationMin = Math.max(0, endMin - startMin); const hours = Math.floor(durationMin / 60); const mins = durationMin % 60; if (hours === 0) return `${mins}m`; if (mins === 0) return `${hours}h`; return `${hours}h ${mins}m`; }; const formatTime12h = (time: string): string => { if (!time) return ""; const [h, m] = time.split(":").map(Number); const period = h >= 12 ? "PM" : "AM"; const hour = h % 12 || 12; return `${hour}:${m.toString().padStart(2, "0")} ${period}`; }; const formatDateDisplay = (date: string): string => { if (!date) return ""; const d = new Date(date + "T12:00:00"); return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", }); }; // ============ HANDLERS ============ // Handler to start editing title const startEditingTitle = handler< Record, { isEditingTitle: Writable } >((_, { isEditingTitle }) => { isEditingTitle.set(true); }); // Handler to stop editing title const stopEditingTitle = handler< Record, { isEditingTitle: Writable } >((_, { isEditingTitle }) => { isEditingTitle.set(false); }); // Handler for keydown on title input (Enter to save) const handleTitleKeydown = handler< { key?: string }, { isEditingTitle: Writable } >((event, { isEditingTitle }) => { if (event?.key === "Enter") { isEditingTitle.set(false); } }); // Handler for clicking on a backlink const handleBacklinkClick = handler< void, { piece: Writable } >((_, { piece }) => navigateTo(piece)); // Auto-update end time when start time changes const onStartTimeChange = handler< { detail: { value: string } }, { startTime: Writable; endTime: Writable } >((e, { startTime, endTime }) => { const newStart = e?.detail?.value; if (newStart) { // Keep same duration or default to 1 hour const oldStart = startTime.get() || "09:00"; const oldEnd = endTime.get() || "10:00"; const duration = timeToMinutes(oldEnd) - timeToMinutes(oldStart); const newEndMin = timeToMinutes(newStart) + Math.max(60, duration); const h = Math.min(23, Math.floor(newEndMin / 60)); const m = newEndMin % 60; endTime.set( `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`, ); } }); // LLM-callable handlers const handleSetTitle = handler< { newTitle: string }, { title: Writable } >(({ newTitle }, { title }) => { title.set(newTitle); return newTitle; }); const handleSetDate = handler< { newDate: string }, { date: Writable } >(({ newDate }, { date }) => { date.set(newDate); return newDate; }); const handleSetTime = handler< { newStartTime: string; newEndTime: string }, { startTime: Writable; endTime: Writable } >(({ newStartTime, newEndTime }, { startTime, endTime }) => { startTime.set(newStartTime); endTime.set(newEndTime); return { startTime: newStartTime, endTime: newEndTime }; }); const handleSetColor = handler< { newColor: string }, { color: Writable } >(({ newColor }, { color }) => { color.set(newColor); return newColor; }); const handleSetNotes = handler< { newNotes: string }, { notes: Writable } >(({ newNotes }, { notes }) => { notes.set(newNotes); return newNotes; }); // Color picker handlers - must be at module scope const setColor0 = handler }>((_, state) => state.color.set(COLORS[0]) ); const setColor1 = handler }>((_, state) => state.color.set(COLORS[1]) ); const setColor2 = handler }>((_, state) => state.color.set(COLORS[2]) ); const setColor3 = handler }>((_, state) => state.color.set(COLORS[3]) ); const setColor4 = handler }>((_, state) => state.color.set(COLORS[4]) ); const setColor5 = handler }>((_, state) => state.color.set(COLORS[5]) ); const colorHandlers = [ setColor0, setColor1, setColor2, setColor3, setColor4, setColor5, ]; // ============ PATTERN ============ const Event = pattern( ({ title, date, startTime, endTime, color, notes, isHidden, eventId }) => { // State for inline title editing const isEditingTitle = Writable.of(false); // Backlinks - populated by backlinks-index.tsx const backlinks = Writable.of([]); // Computed display values const dateDisplay = computed(() => formatDateDisplay(date.get())); const timeDisplay = computed(() => `${formatTime12h(startTime.get())} - ${formatTime12h(endTime.get())}` ); const durationDisplay = computed(() => formatDuration(startTime.get(), endTime.get()) ); return { [NAME]: computed(() => `${title.get()}`), [UI]: ( {/* Header */} {/* Editable Title - click to edit */}
isEditingTitle.get() ? "none" : "flex" ), alignItems: "center", gap: "8px", cursor: "pointer", flex: 1, }} onClick={startEditingTitle({ isEditingTitle })} >
color.get()), flexShrink: 0, }} /> {title}
isEditingTitle.get() ? "flex" : "none" ), flex: 1, marginRight: "12px", }} >
{/* Date/Time summary */} {dateDisplay} | {timeDisplay} ({durationDisplay}) {/* Main Content */} {/* Date Input */}
{/* Time Inputs */}
{/* Color Picker */}
{COLORS.map((c, idx) => (
color.get() === c ? "2px solid #111" : "2px solid transparent" ), }} onClick={colorHandlers[idx]({ color })} /> ))}
{/* Notes */}
{/* 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, date, startTime, endTime, color, notes, summary: computed(() => { const t = title.get(); const d = date.get() ? formatDateDisplay(date.get()) : ""; const st = startTime.get() ? formatTime12h(startTime.get()) : ""; const et = endTime.get() ? formatTime12h(endTime.get()) : ""; const n = notes.get(); return `${t}${d ? ` on ${d}` : ""}${st ? ` ${st}` : ""}${ et ? `-${et}` : "" }${n ? `: ${n.slice(0, 150)}` : ""}`; }), isHidden, eventId, backlinks, // LLM-callable streams setTitle: handleSetTitle({ title }), setDate: handleSetDate({ date }), setTime: handleSetTime({ startTime, endTime }), setColor: handleSetColor({ color }), setNotes: handleSetNotes({ notes }), }; }, ); export default Event; export { COLORS, generateId };