Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat(calendar): add AI chat with event actions' (#323) from feat/calendar-ai-chat into main

scott 4434132a 1dc9fd70

+811 -3
+86
src/calendar/ai-calendar-actions.ts
··· 1 + /** 2 + * AI Calendar Actions — executor for calendar AI actions. 3 + */ 4 + 5 + import type { CalAddEventAction, CalModifyEventAction, CalRemoveEventAction, CalendarAction } from '../lib/ai-actions.js'; 6 + import type { CalendarEvent } from './helpers.js'; 7 + import { EVENT_COLORS } from './helpers.js'; 8 + 9 + export interface ActionResult { 10 + success: boolean; 11 + error?: string; 12 + } 13 + 14 + export interface CalendarActionDeps { 15 + getEvents: () => CalendarEvent[]; 16 + addEvent: (event: CalendarEvent) => void; 17 + updateEvent: (id: string, updates: Partial<CalendarEvent>) => void; 18 + removeEvent: (id: string) => void; 19 + } 20 + 21 + /** Find event by title, optionally narrowed by date */ 22 + export function findEvent(events: CalendarEvent[], title: string, date?: string): CalendarEvent | undefined { 23 + const lower = title.toLowerCase(); 24 + const matches = events.filter(e => e.title.toLowerCase().includes(lower)); 25 + if (matches.length === 0) return undefined; 26 + if (date && matches.length > 1) { 27 + return matches.find(e => e.date === date) || matches[0]; 28 + } 29 + return matches[0]; 30 + } 31 + 32 + export function executeCalendarAction(action: CalendarAction, deps: CalendarActionDeps): ActionResult { 33 + switch (action.type) { 34 + case 'cal_add_event': 35 + return executeAddEvent(action, deps); 36 + case 'cal_modify_event': 37 + return executeModifyEvent(action, deps); 38 + case 'cal_remove_event': 39 + return executeRemoveEvent(action, deps); 40 + } 41 + } 42 + 43 + function executeAddEvent(action: CalAddEventAction, deps: CalendarActionDeps): ActionResult { 44 + const now = Date.now(); 45 + const event: CalendarEvent = { 46 + id: crypto.randomUUID(), 47 + title: action.title, 48 + date: action.date, 49 + startTime: action.allDay ? '' : (action.startTime || '09:00'), 50 + endTime: action.allDay ? '' : (action.endTime || '10:00'), 51 + allDay: action.allDay ?? (!action.startTime && !action.endTime), 52 + color: action.color || EVENT_COLORS[Math.floor(Math.random() * EVENT_COLORS.length)] || '#3a8a7a', 53 + description: action.description || '', 54 + createdAt: now, 55 + updatedAt: now, 56 + }; 57 + deps.addEvent(event); 58 + return { success: true }; 59 + } 60 + 61 + function executeModifyEvent(action: CalModifyEventAction, deps: CalendarActionDeps): ActionResult { 62 + const event = findEvent(deps.getEvents(), action.title, action.date); 63 + if (!event) return { success: false, error: `Event "${action.title}" not found` }; 64 + 65 + const updates: Partial<CalendarEvent> = { updatedAt: Date.now() }; 66 + if (action.newTitle !== undefined) updates.title = action.newTitle; 67 + if (action.newDate !== undefined) updates.date = action.newDate; 68 + if (action.startTime !== undefined) updates.startTime = action.startTime; 69 + if (action.endTime !== undefined) updates.endTime = action.endTime; 70 + if (action.allDay !== undefined) { 71 + updates.allDay = action.allDay; 72 + if (action.allDay) { updates.startTime = ''; updates.endTime = ''; } 73 + } 74 + if (action.color !== undefined) updates.color = action.color; 75 + if (action.description !== undefined) updates.description = action.description; 76 + 77 + deps.updateEvent(event.id, updates); 78 + return { success: true }; 79 + } 80 + 81 + function executeRemoveEvent(action: CalRemoveEventAction, deps: CalendarActionDeps): ActionResult { 82 + const event = findEvent(deps.getEvents(), action.title, action.date); 83 + if (!event) return { success: false, error: `Event "${action.title}" not found` }; 84 + deps.removeEvent(event.id); 85 + return { success: true }; 86 + }
+190
src/calendar/ai-chat-panel.ts
··· 1 + /** 2 + * AI chat panel logic for calendar — context extraction and message sending. 3 + * 4 + * Follows the same pattern as forms/ai-chat-panel.ts. 5 + */ 6 + 7 + import type { CalendarEvent } from './helpers.js'; 8 + import { formatTimeDisplay } from './helpers.js'; 9 + import { executeCalendarAction } from './ai-calendar-actions.js'; 10 + import { escapeHtml } from '../lib/ai-chat.js'; 11 + import { 12 + isConfigured, buildSystemMessage, streamChat, 13 + appendMessage, appendStreamingBubble, renderMarkdown, 14 + appendActionCard, 15 + type ChatMessage, 16 + } from '../lib/ai-chat.js'; 17 + import { splitResponse, isCalendarAction } from '../lib/ai-actions.js'; 18 + 19 + // ── Types ─────────────────────────────────────────────────── 20 + 21 + export interface ChatPanelDeps { 22 + getEvents: () => CalendarEvent[]; 23 + addEvent: (event: CalendarEvent) => void; 24 + updateEvent: (id: string, updates: Partial<CalendarEvent>) => void; 25 + removeEvent: (id: string) => void; 26 + renderView: () => void; 27 + chatUI: { 28 + input: HTMLTextAreaElement; 29 + settingsPanel: HTMLElement; 30 + endpointInput: HTMLInputElement; 31 + messageList: HTMLElement; 32 + sendBtn: HTMLElement; 33 + stopBtn: HTMLElement; 34 + contextToggle: HTMLInputElement; 35 + actionsToggle: HTMLInputElement; 36 + }; 37 + chatState: { 38 + messages: ChatMessage[]; 39 + loading: boolean; 40 + error: string | null; 41 + abortController: AbortController | null; 42 + }; 43 + chatWiring: { getConfig: () => ReturnType<typeof import('../lib/ai-chat.js').loadConfig> }; 44 + titleInput: HTMLInputElement; 45 + currentDate: () => Date; 46 + } 47 + 48 + // ── Calendar context extraction ───────────────────────────── 49 + 50 + export function getCalendarContextText(events: CalendarEvent[], currentDate: Date): string { 51 + const lines: string[] = []; 52 + const dateStr = currentDate.toISOString().slice(0, 10); 53 + lines.push(`Current date: ${dateStr}`); 54 + lines.push(`Total events: ${events.length}`); 55 + 56 + if (events.length === 0) { 57 + lines.push('No events in the calendar.'); 58 + return lines.join('\n'); 59 + } 60 + 61 + // Sort events by date then time 62 + const sorted = [...events].sort((a, b) => { 63 + if (a.date !== b.date) return a.date < b.date ? -1 : 1; 64 + return (a.startTime || '').localeCompare(b.startTime || ''); 65 + }); 66 + 67 + // Show events around current date (2 weeks before and 4 weeks after) 68 + const twoWeeksBefore = new Date(currentDate); 69 + twoWeeksBefore.setDate(twoWeeksBefore.getDate() - 14); 70 + const fourWeeksAfter = new Date(currentDate); 71 + fourWeeksAfter.setDate(fourWeeksAfter.getDate() + 28); 72 + 73 + const beforeStr = twoWeeksBefore.toISOString().slice(0, 10); 74 + const afterStr = fourWeeksAfter.toISOString().slice(0, 10); 75 + 76 + const nearby = sorted.filter(e => e.date >= beforeStr && e.date <= afterStr); 77 + const showing = nearby.slice(0, 50); // cap context size 78 + 79 + lines.push(''); 80 + lines.push(`Events (${beforeStr} to ${afterStr}):`); 81 + for (const evt of showing) { 82 + const timeStr = evt.allDay 83 + ? 'All day' 84 + : `${formatTimeDisplay(evt.startTime)}-${formatTimeDisplay(evt.endTime)}`; 85 + let line = `- ${evt.date} ${timeStr}: "${evt.title}"`; 86 + if (evt.description) line += ` — ${evt.description.slice(0, 80)}`; 87 + lines.push(line); 88 + } 89 + 90 + if (nearby.length > 50) { 91 + lines.push(`... and ${nearby.length - 50} more events`); 92 + } 93 + 94 + return lines.join('\n'); 95 + } 96 + 97 + // ── Send chat message ─────────────────────────────────────── 98 + 99 + export async function sendChatMessage(deps: ChatPanelDeps): Promise<void> { 100 + const text = deps.chatUI.input.value.trim(); 101 + if (!text || deps.chatState.loading) return; 102 + 103 + const cfg = deps.chatWiring.getConfig(); 104 + if (!isConfigured(cfg)) { 105 + deps.chatUI.settingsPanel.style.display = ''; 106 + deps.chatUI.endpointInput.focus(); 107 + return; 108 + } 109 + 110 + const userMsg: ChatMessage = { role: 'user', content: text, ts: Date.now() }; 111 + deps.chatState.messages.push(userMsg); 112 + appendMessage(deps.chatUI.messageList, userMsg); 113 + 114 + deps.chatUI.input.value = ''; 115 + deps.chatUI.input.style.height = ''; 116 + deps.chatUI.sendBtn.style.display = 'none'; 117 + deps.chatUI.stopBtn.style.display = ''; 118 + deps.chatState.loading = true; 119 + deps.chatState.error = null; 120 + 121 + const title = deps.titleInput.value.trim() || 'Untitled Calendar'; 122 + const includeContext = deps.chatUI.contextToggle.checked; 123 + const actionsEnabled = deps.chatUI.actionsToggle.checked; 124 + const contextText = includeContext ? getCalendarContextText(deps.getEvents(), deps.currentDate()) : ''; 125 + 126 + const systemPrompt = buildSystemMessage(title, contextText, { 127 + editorType: 'calendar', 128 + actionsEnabled, 129 + }); 130 + 131 + const calDeps = { 132 + getEvents: deps.getEvents, 133 + addEvent: deps.addEvent, 134 + updateEvent: deps.updateEvent, 135 + removeEvent: deps.removeEvent, 136 + }; 137 + 138 + const abortController = new AbortController(); 139 + deps.chatState.abortController = abortController; 140 + const bubble = appendStreamingBubble(deps.chatUI.messageList); 141 + let fullText = ''; 142 + 143 + await streamChat( 144 + cfg, 145 + deps.chatState.messages, 146 + systemPrompt, 147 + { 148 + onChunk(chunk) { 149 + fullText += chunk; 150 + bubble.update(renderMarkdown(fullText)); 151 + }, 152 + onDone(doneText) { 153 + if (doneText) { 154 + deps.chatState.messages.push({ role: 'assistant', content: doneText, ts: Date.now() }); 155 + 156 + if (actionsEnabled) { 157 + const { displayText, actions } = splitResponse(doneText); 158 + if (actions.length > 0) { 159 + bubble.update(renderMarkdown(displayText)); 160 + for (const action of actions) { 161 + if (!isCalendarAction(action)) continue; 162 + appendActionCard(deps.chatUI.messageList, action, { 163 + onApply: (a) => { 164 + const result = executeCalendarAction(a as Parameters<typeof executeCalendarAction>[0], calDeps); 165 + if (!result.success && result.error) { 166 + appendMessage(deps.chatUI.messageList, { role: 'assistant', content: `Action failed: ${result.error}`, ts: Date.now() }); 167 + } 168 + deps.renderView(); 169 + }, 170 + onDismiss: () => {}, 171 + }); 172 + } 173 + } 174 + } 175 + } 176 + }, 177 + onError(err) { 178 + deps.chatState.error = err; 179 + bubble.el.classList.add('ai-chat-bubble--error'); 180 + bubble.update(`<span class="ai-chat-error">${escapeHtml(err)}</span>`); 181 + }, 182 + }, 183 + abortController.signal, 184 + ); 185 + 186 + deps.chatState.loading = false; 187 + deps.chatState.abortController = null; 188 + deps.chatUI.sendBtn.style.display = ''; 189 + deps.chatUI.stopBtn.style.display = 'none'; 190 + }
+39
src/calendar/main.ts
··· 36 36 import { parseIcsFile } from './ics-parser.js'; 37 37 import { exportIcsFile } from './ics-export.js'; 38 38 import { showToast } from '../landing-toast.js'; 39 + import { createChatSidebar, createChatState, loadConfig, initChatWiring } from '../lib/ai-chat.js'; 40 + import { sendChatMessage, type ChatPanelDeps } from './ai-chat-panel.js'; 39 41 40 42 // --------------------------------------------------------------------------- 41 43 // Types & constants ··· 1252 1254 body: JSON.stringify({ name_encrypted: b64 }), 1253 1255 }); 1254 1256 }); 1257 + 1258 + // --------------------------------------------------------------------------- 1259 + // AI Chat Panel 1260 + // --------------------------------------------------------------------------- 1261 + 1262 + const chatUI = createChatSidebar(); 1263 + document.getElementById('main-content')!.appendChild(chatUI.container); 1264 + 1265 + const chatState = createChatState(); 1266 + 1267 + const chatDeps: ChatPanelDeps = { 1268 + getEvents: () => state.events, 1269 + addEvent: (event) => addEventToYjs(event), 1270 + updateEvent: (id, updates) => { 1271 + const existing = state.events.find(e => e.id === id); 1272 + if (!existing) return; 1273 + updateEventInYjs({ ...existing, ...updates }); 1274 + }, 1275 + removeEvent: (id) => deleteEventFromYjs(id), 1276 + renderView, 1277 + chatUI, 1278 + chatState, 1279 + chatWiring: null!, // assigned below after initChatWiring 1280 + titleInput, 1281 + currentDate: () => state.currentDate, 1282 + }; 1283 + 1284 + const chatWiring = initChatWiring({ 1285 + chatUI, 1286 + chatState, 1287 + chatConfig: loadConfig(), 1288 + toggleBtn: document.getElementById('btn-ai-chat')!, 1289 + editorType: 'calendar', 1290 + onSend: () => sendChatMessage(chatDeps), 1291 + }); 1292 + 1293 + chatDeps.chatWiring = chatWiring; 1255 1294 1256 1295 // --------------------------------------------------------------------------- 1257 1296 // Command Palette
+55 -2
src/lib/ai-actions.ts
··· 117 117 fill?: string; 118 118 } 119 119 120 + // Calendar actions 121 + export interface CalAddEventAction { 122 + type: 'cal_add_event'; 123 + title: string; 124 + date: string; // YYYY-MM-DD 125 + startTime?: string; // HH:MM 126 + endTime?: string; // HH:MM 127 + allDay?: boolean; 128 + color?: string; 129 + description?: string; 130 + } 131 + 132 + export interface CalModifyEventAction { 133 + type: 'cal_modify_event'; 134 + title: string; // find by title 135 + date?: string; // optional: disambiguate if multiple events share title 136 + newTitle?: string; 137 + newDate?: string; 138 + startTime?: string; 139 + endTime?: string; 140 + allDay?: boolean; 141 + color?: string; 142 + description?: string; 143 + } 144 + 145 + export interface CalRemoveEventAction { 146 + type: 'cal_remove_event'; 147 + title: string; 148 + date?: string; // optional disambiguation 149 + } 150 + 120 151 // Form actions 121 152 export interface FormAddQuestionAction { 122 153 type: 'form_add_question'; ··· 143 174 export type SheetAction = SheetSetAction | SheetClearAction; 144 175 export type DiagramAction = DiagramAddShapeAction | DiagramAddArrowAction | DiagramModifyShapeAction | DiagramRemoveShapeAction | DiagramAddTextAction; 145 176 export type SlideAction = SlideAddAction | SlideAddTextAction | SlideAddShapeAction; 177 + export type CalendarAction = CalAddEventAction | CalModifyEventAction | CalRemoveEventAction; 146 178 export type FormAction = FormAddQuestionAction | FormModifyQuestionAction | FormRemoveQuestionAction; 147 - export type AIAction = DocAction | SheetAction | DiagramAction | SlideAction | FormAction; 179 + export type AIAction = DocAction | SheetAction | DiagramAction | SlideAction | FormAction | CalendarAction; 148 180 149 181 // ── Validation ──────────────────────────────────────────────────────── 150 182 ··· 161 193 const DIAGRAM_ACTION_TYPES = new Set(['diagram_add_shape', 'diagram_add_arrow', 'diagram_modify_shape', 'diagram_remove_shape', 'diagram_add_text']); 162 194 const SLIDE_ACTION_TYPES = new Set(['slide_add', 'slide_add_text', 'slide_add_shape']); 163 195 const FORM_ACTION_TYPES = new Set(['form_add_question', 'form_modify_question', 'form_remove_question']); 196 + const CALENDAR_ACTION_TYPES = new Set(['cal_add_event', 'cal_modify_event', 'cal_remove_event']); 164 197 const VALID_POSITIONS = new Set(['cursor', 'start', 'end']); 165 198 166 199 export function validateAction(action: unknown): ValidationResult { ··· 174 207 return { valid: false, error: 'Action must have a string "type" field' }; 175 208 } 176 209 177 - if (!DOC_ACTION_TYPES.has(a.type) && !SHEET_ACTION_TYPES.has(a.type) && !DIAGRAM_ACTION_TYPES.has(a.type) && !SLIDE_ACTION_TYPES.has(a.type) && !FORM_ACTION_TYPES.has(a.type)) { 210 + if (!DOC_ACTION_TYPES.has(a.type) && !SHEET_ACTION_TYPES.has(a.type) && !DIAGRAM_ACTION_TYPES.has(a.type) && !SLIDE_ACTION_TYPES.has(a.type) && !FORM_ACTION_TYPES.has(a.type) && !CALENDAR_ACTION_TYPES.has(a.type)) { 178 211 return { valid: false, error: `Unknown action type: ${a.type}` }; 179 212 } 180 213 ··· 260 293 case 'form_remove_question': 261 294 if (typeof a.title !== 'string') return { valid: false, error: 'form_remove_question requires "title"' }; 262 295 break; 296 + case 'cal_add_event': 297 + if (typeof a.title !== 'string') return { valid: false, error: 'cal_add_event requires "title"' }; 298 + if (typeof a.date !== 'string') return { valid: false, error: 'cal_add_event requires "date"' }; 299 + break; 300 + case 'cal_modify_event': 301 + if (typeof a.title !== 'string') return { valid: false, error: 'cal_modify_event requires "title"' }; 302 + break; 303 + case 'cal_remove_event': 304 + if (typeof a.title !== 'string') return { valid: false, error: 'cal_remove_event requires "title"' }; 305 + break; 263 306 } 264 307 265 308 return { valid: true }; ··· 363 406 return `Modify question "${action.title.slice(0, 40)}${action.title.length > 40 ? '...' : ''}"`; 364 407 case 'form_remove_question': 365 408 return `Remove question "${action.title}"`; 409 + case 'cal_add_event': 410 + return `Add event "${action.title.slice(0, 40)}${action.title.length > 40 ? '...' : ''}" on ${action.date}`; 411 + case 'cal_modify_event': 412 + return `Modify event "${action.title.slice(0, 40)}${action.title.length > 40 ? '...' : ''}"`; 413 + case 'cal_remove_event': 414 + return `Remove event "${action.title}"`; 366 415 } 367 416 } 368 417 ··· 387 436 export function isFormAction(action: AIAction): action is FormAction { 388 437 return FORM_ACTION_TYPES.has(action.type); 389 438 } 439 + 440 + export function isCalendarAction(action: AIAction): action is CalendarAction { 441 + return CALENDAR_ACTION_TYPES.has(action.type); 442 + }
+24 -1
src/lib/ai-chat/system-prompt.ts
··· 4 4 5 5 // ── System prompt ────────────────────────────────────────────────────── 6 6 7 - export type EditorType = 'doc' | 'sheet' | 'diagram' | 'slide' | 'form'; 7 + export type EditorType = 'doc' | 'sheet' | 'diagram' | 'slide' | 'form' | 'calendar'; 8 8 9 9 export interface SystemMessageOptions { 10 10 editorType?: EditorType; ··· 25 25 diagram: { role: 'a helpful diagramming assistant embedded in a whiteboard/diagram editor', label: 'diagram' }, 26 26 slide: { role: 'a helpful presentation assistant embedded in a slide deck editor', label: 'presentation' }, 27 27 form: { role: 'a helpful form-building assistant embedded in a form builder', label: 'form' }, 28 + calendar: { role: 'a helpful scheduling assistant embedded in a calendar app', label: 'calendar' }, 28 29 }; 29 30 const { role, label } = descriptions[editorType]; 30 31 const parts = [ ··· 152 153 '- **slide_add_shape**: Add a shape to the current slide.', 153 154 ' ```action', 154 155 ' {"type": "slide_add_shape", "element": "rectangle", "x": 100, "y": 200, "w": 200, "h": 100, "fill": "#4A90D9"}', 156 + ' ```', 157 + ); 158 + } else if (editorType === 'calendar') { 159 + lines.push( 160 + 'Available calendar actions:', 161 + '', 162 + '- **cal_add_event**: Add an event to the calendar.', 163 + ' ```action', 164 + ' {"type": "cal_add_event", "title": "Team Meeting", "date": "2026-04-10", "startTime": "14:00", "endTime": "15:00"}', 165 + ' ```', 166 + ' For all-day events: `{"type": "cal_add_event", "title": "Vacation", "date": "2026-04-10", "allDay": true}`', 167 + ' Optional fields: color (hex), description.', 168 + '', 169 + '- **cal_modify_event**: Modify an existing event by title.', 170 + ' ```action', 171 + ' {"type": "cal_modify_event", "title": "Team Meeting", "newTitle": "All Hands", "startTime": "15:00", "endTime": "16:00"}', 172 + ' ```', 173 + ' Only include fields you want to change. Use "date" to disambiguate if multiple events share a title.', 174 + '', 175 + '- **cal_remove_event**: Remove an event by title.', 176 + ' ```action', 177 + ' {"type": "cal_remove_event", "title": "Team Meeting"}', 155 178 ' ```', 156 179 ); 157 180 } else if (editorType === 'form') {
+1
src/lib/ai-chat/wiring.ts
··· 137 137 diagram: { label: 'diagram', contextWord: 'shapes and arrows' }, 138 138 slide: { label: 'presentation', contextWord: 'slides' }, 139 139 form: { label: 'form', contextWord: 'questions' }, 140 + calendar: { label: 'calendar', contextWord: 'events' }, 140 141 }; 141 142 const { label, contextWord } = editorLabels[editorType] || editorLabels.doc; 142 143 chatUI.clearBtn.addEventListener('click', () => {
+108
tests/ai-actions.test.ts
··· 10 10 isDiagramAction, 11 11 isSlideAction, 12 12 isFormAction, 13 + isCalendarAction, 13 14 type AIAction, 14 15 } from '../src/lib/ai-actions.js'; 15 16 ··· 607 608 it('handles text with only whitespace', () => { 608 609 const { displayText } = splitResponse(' \n\n '); 609 610 expect(displayText.trim()).toBe(''); 611 + }); 612 + }); 613 + 614 + describe('validateAction — calendar types', () => { 615 + it('validates cal_add_event', () => { 616 + expect(validateAction({ type: 'cal_add_event', title: 'Meeting', date: '2026-04-10' }).valid).toBe(true); 617 + }); 618 + 619 + it('validates cal_add_event with all fields', () => { 620 + expect(validateAction({ 621 + type: 'cal_add_event', title: 'Meeting', date: '2026-04-10', 622 + startTime: '14:00', endTime: '15:00', allDay: false, color: '#ff0000', description: 'Weekly sync', 623 + }).valid).toBe(true); 624 + }); 625 + 626 + it('rejects cal_add_event without title', () => { 627 + expect(validateAction({ type: 'cal_add_event', date: '2026-04-10' }).valid).toBe(false); 628 + }); 629 + 630 + it('rejects cal_add_event without date', () => { 631 + expect(validateAction({ type: 'cal_add_event', title: 'Meeting' }).valid).toBe(false); 632 + }); 633 + 634 + it('validates cal_modify_event', () => { 635 + expect(validateAction({ type: 'cal_modify_event', title: 'Meeting' }).valid).toBe(true); 636 + }); 637 + 638 + it('rejects cal_modify_event without title', () => { 639 + expect(validateAction({ type: 'cal_modify_event' }).valid).toBe(false); 640 + }); 641 + 642 + it('validates cal_remove_event', () => { 643 + expect(validateAction({ type: 'cal_remove_event', title: 'Meeting' }).valid).toBe(true); 644 + }); 645 + 646 + it('rejects cal_remove_event without title', () => { 647 + expect(validateAction({ type: 'cal_remove_event' }).valid).toBe(false); 648 + }); 649 + }); 650 + 651 + describe('describeAction — calendar types', () => { 652 + it('describes cal_add_event', () => { 653 + const desc = describeAction({ type: 'cal_add_event', title: 'Team Meeting', date: '2026-04-10' } as AIAction); 654 + expect(desc).toContain('Add event'); 655 + expect(desc).toContain('Team Meeting'); 656 + expect(desc).toContain('2026-04-10'); 657 + }); 658 + 659 + it('describes cal_add_event and truncates long title', () => { 660 + const longTitle = 'M'.repeat(60); 661 + const desc = describeAction({ type: 'cal_add_event', title: longTitle, date: '2026-04-10' } as AIAction); 662 + expect(desc).toContain('...'); 663 + }); 664 + 665 + it('describes cal_modify_event', () => { 666 + const desc = describeAction({ type: 'cal_modify_event', title: 'Meeting' } as AIAction); 667 + expect(desc).toContain('Modify event'); 668 + expect(desc).toContain('Meeting'); 669 + }); 670 + 671 + it('describes cal_remove_event', () => { 672 + const desc = describeAction({ type: 'cal_remove_event', title: 'Meeting' } as AIAction); 673 + expect(desc).toContain('Remove event'); 674 + expect(desc).toContain('Meeting'); 675 + }); 676 + }); 677 + 678 + describe('isCalendarAction', () => { 679 + it('identifies all calendar action types', () => { 680 + expect(isCalendarAction({ type: 'cal_add_event', title: 'X', date: '2026-04-10' })).toBe(true); 681 + expect(isCalendarAction({ type: 'cal_modify_event', title: 'X' })).toBe(true); 682 + expect(isCalendarAction({ type: 'cal_remove_event', title: 'X' })).toBe(true); 683 + }); 684 + 685 + it('calendar actions are not other action types', () => { 686 + const action = { type: 'cal_add_event', title: 'X', date: '2026-04-10' } as AIAction; 687 + expect(isCalendarAction(action)).toBe(true); 688 + expect(isDocAction(action)).toBe(false); 689 + expect(isSheetAction(action)).toBe(false); 690 + expect(isDiagramAction(action)).toBe(false); 691 + expect(isSlideAction(action)).toBe(false); 692 + expect(isFormAction(action)).toBe(false); 693 + }); 694 + 695 + it('other action types are not calendar actions', () => { 696 + expect(isCalendarAction({ type: 'doc_insert', position: 'end', content: 'x' } as AIAction)).toBe(false); 697 + expect(isCalendarAction({ type: 'form_add_question', questionType: 'text', title: 'Q' } as AIAction)).toBe(false); 698 + }); 699 + }); 700 + 701 + describe('parseActions — calendar blocks', () => { 702 + it('parses a calendar action block', () => { 703 + const text = '```action\n{"type":"cal_add_event","title":"Meeting","date":"2026-04-10","startTime":"14:00","endTime":"15:00"}\n```'; 704 + const { actions, errors } = parseActions(text); 705 + expect(actions).toHaveLength(1); 706 + expect(actions[0].type).toBe('cal_add_event'); 707 + expect(errors).toHaveLength(0); 708 + }); 709 + 710 + it('parses calendar action alongside other types', () => { 711 + const text = [ 712 + '```action\n{"type":"doc_insert","position":"end","content":"Hi"}\n```', 713 + '```action\n{"type":"cal_add_event","title":"Meeting","date":"2026-04-10"}\n```', 714 + ].join('\n\n'); 715 + const { actions } = parseActions(text); 716 + expect(actions).toHaveLength(2); 717 + expect(actions.map(a => a.type)).toEqual(['doc_insert', 'cal_add_event']); 610 718 }); 611 719 }); 612 720
+308
tests/ai-calendar-actions.test.ts
··· 1 + /** 2 + * Tests for AI calendar action executor. 3 + */ 4 + import { describe, it, expect, vi } from 'vitest'; 5 + import { executeCalendarAction, findEvent } from '../src/calendar/ai-calendar-actions.js'; 6 + import type { CalendarAction } from '../src/lib/ai-actions.js'; 7 + import type { CalendarEvent } from '../src/calendar/helpers.js'; 8 + 9 + function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent { 10 + return { 11 + id: crypto.randomUUID(), 12 + title: 'Test Event', 13 + date: '2026-04-10', 14 + startTime: '09:00', 15 + endTime: '10:00', 16 + allDay: false, 17 + color: '#3a8a7a', 18 + description: '', 19 + createdAt: Date.now(), 20 + updatedAt: Date.now(), 21 + ...overrides, 22 + }; 23 + } 24 + 25 + function makeDeps(initial: CalendarEvent[] = []) { 26 + let events = [...initial]; 27 + const addEvent = vi.fn((event: CalendarEvent) => { events.push(event); }); 28 + const updateEvent = vi.fn((id: string, updates: Partial<CalendarEvent>) => { 29 + events = events.map(e => e.id === id ? { ...e, ...updates } : e); 30 + }); 31 + const removeEvent = vi.fn((id: string) => { 32 + events = events.filter(e => e.id !== id); 33 + }); 34 + return { 35 + getEvents: () => events, 36 + addEvent, 37 + updateEvent, 38 + removeEvent, 39 + get events() { return events; }, 40 + }; 41 + } 42 + 43 + describe('findEvent', () => { 44 + it('finds event by exact title match', () => { 45 + const events = [makeEvent({ title: 'Team Meeting' })]; 46 + const found = findEvent(events, 'Team Meeting'); 47 + expect(found).toBeDefined(); 48 + expect(found!.title).toBe('Team Meeting'); 49 + }); 50 + 51 + it('finds event by partial title match (case insensitive)', () => { 52 + const events = [makeEvent({ title: 'Team Meeting' })]; 53 + const found = findEvent(events, 'team'); 54 + expect(found).toBeDefined(); 55 + expect(found!.title).toBe('Team Meeting'); 56 + }); 57 + 58 + it('returns undefined when no match', () => { 59 + const events = [makeEvent({ title: 'Team Meeting' })]; 60 + const found = findEvent(events, 'Lunch'); 61 + expect(found).toBeUndefined(); 62 + }); 63 + 64 + it('disambiguates by date when multiple matches', () => { 65 + const events = [ 66 + makeEvent({ title: 'Standup', date: '2026-04-10' }), 67 + makeEvent({ title: 'Standup', date: '2026-04-11' }), 68 + ]; 69 + const found = findEvent(events, 'Standup', '2026-04-11'); 70 + expect(found).toBeDefined(); 71 + expect(found!.date).toBe('2026-04-11'); 72 + }); 73 + 74 + it('returns first match when date does not narrow further', () => { 75 + const events = [ 76 + makeEvent({ title: 'Standup', date: '2026-04-10' }), 77 + makeEvent({ title: 'Standup', date: '2026-04-11' }), 78 + ]; 79 + const found = findEvent(events, 'Standup', '2026-04-15'); 80 + expect(found).toBeDefined(); 81 + expect(found!.date).toBe('2026-04-10'); 82 + }); 83 + 84 + it('returns first match when only one exists and date not specified', () => { 85 + const events = [ 86 + makeEvent({ title: 'Standup', date: '2026-04-10' }), 87 + makeEvent({ title: 'Standup', date: '2026-04-11' }), 88 + ]; 89 + const found = findEvent(events, 'Standup'); 90 + expect(found).toBeDefined(); 91 + expect(found!.date).toBe('2026-04-10'); 92 + }); 93 + }); 94 + 95 + describe('executeCalendarAction', () => { 96 + describe('cal_add_event', () => { 97 + it('adds a timed event', () => { 98 + const deps = makeDeps(); 99 + const action: CalendarAction = { 100 + type: 'cal_add_event', 101 + title: 'Team Meeting', 102 + date: '2026-04-10', 103 + startTime: '14:00', 104 + endTime: '15:00', 105 + }; 106 + const result = executeCalendarAction(action, deps); 107 + expect(result.success).toBe(true); 108 + expect(deps.addEvent).toHaveBeenCalledTimes(1); 109 + const added = deps.addEvent.mock.calls[0][0]; 110 + expect(added.title).toBe('Team Meeting'); 111 + expect(added.date).toBe('2026-04-10'); 112 + expect(added.startTime).toBe('14:00'); 113 + expect(added.endTime).toBe('15:00'); 114 + expect(added.allDay).toBe(false); 115 + expect(added.id).toBeTruthy(); 116 + }); 117 + 118 + it('adds an all-day event', () => { 119 + const deps = makeDeps(); 120 + const action: CalendarAction = { 121 + type: 'cal_add_event', 122 + title: 'Vacation', 123 + date: '2026-04-10', 124 + allDay: true, 125 + }; 126 + const result = executeCalendarAction(action, deps); 127 + expect(result.success).toBe(true); 128 + const added = deps.addEvent.mock.calls[0][0]; 129 + expect(added.allDay).toBe(true); 130 + expect(added.startTime).toBe(''); 131 + expect(added.endTime).toBe(''); 132 + }); 133 + 134 + it('defaults to all-day when no times provided', () => { 135 + const deps = makeDeps(); 136 + const action: CalendarAction = { 137 + type: 'cal_add_event', 138 + title: 'Holiday', 139 + date: '2026-12-25', 140 + }; 141 + const result = executeCalendarAction(action, deps); 142 + expect(result.success).toBe(true); 143 + const added = deps.addEvent.mock.calls[0][0]; 144 + expect(added.allDay).toBe(true); 145 + }); 146 + 147 + it('uses default times when not all-day and times omitted', () => { 148 + const deps = makeDeps(); 149 + const action: CalendarAction = { 150 + type: 'cal_add_event', 151 + title: 'Quick Chat', 152 + date: '2026-04-10', 153 + allDay: false, 154 + }; 155 + executeCalendarAction(action, deps); 156 + const added = deps.addEvent.mock.calls[0][0]; 157 + expect(added.startTime).toBe('09:00'); 158 + expect(added.endTime).toBe('10:00'); 159 + }); 160 + 161 + it('includes color and description when provided', () => { 162 + const deps = makeDeps(); 163 + const action: CalendarAction = { 164 + type: 'cal_add_event', 165 + title: 'Design Review', 166 + date: '2026-04-10', 167 + startTime: '10:00', 168 + endTime: '11:00', 169 + color: '#ff0000', 170 + description: 'Review the Q2 designs', 171 + }; 172 + executeCalendarAction(action, deps); 173 + const added = deps.addEvent.mock.calls[0][0]; 174 + expect(added.color).toBe('#ff0000'); 175 + expect(added.description).toBe('Review the Q2 designs'); 176 + }); 177 + }); 178 + 179 + describe('cal_modify_event', () => { 180 + it('modifies an existing event title', () => { 181 + const evt = makeEvent({ title: 'Team Meeting' }); 182 + const deps = makeDeps([evt]); 183 + const action: CalendarAction = { 184 + type: 'cal_modify_event', 185 + title: 'Team Meeting', 186 + newTitle: 'All Hands', 187 + }; 188 + const result = executeCalendarAction(action, deps); 189 + expect(result.success).toBe(true); 190 + expect(deps.updateEvent).toHaveBeenCalledWith(evt.id, expect.objectContaining({ 191 + title: 'All Hands', 192 + })); 193 + }); 194 + 195 + it('modifies event times', () => { 196 + const evt = makeEvent({ title: 'Standup', startTime: '09:00', endTime: '09:30' }); 197 + const deps = makeDeps([evt]); 198 + const action: CalendarAction = { 199 + type: 'cal_modify_event', 200 + title: 'Standup', 201 + startTime: '10:00', 202 + endTime: '10:30', 203 + }; 204 + const result = executeCalendarAction(action, deps); 205 + expect(result.success).toBe(true); 206 + expect(deps.updateEvent).toHaveBeenCalledWith(evt.id, expect.objectContaining({ 207 + startTime: '10:00', 208 + endTime: '10:30', 209 + })); 210 + }); 211 + 212 + it('modifies event to all-day and clears times', () => { 213 + const evt = makeEvent({ title: 'Workshop', startTime: '09:00', endTime: '17:00' }); 214 + const deps = makeDeps([evt]); 215 + const action: CalendarAction = { 216 + type: 'cal_modify_event', 217 + title: 'Workshop', 218 + allDay: true, 219 + }; 220 + executeCalendarAction(action, deps); 221 + expect(deps.updateEvent).toHaveBeenCalledWith(evt.id, expect.objectContaining({ 222 + allDay: true, 223 + startTime: '', 224 + endTime: '', 225 + })); 226 + }); 227 + 228 + it('modifies event date', () => { 229 + const evt = makeEvent({ title: 'Lunch', date: '2026-04-10' }); 230 + const deps = makeDeps([evt]); 231 + const action: CalendarAction = { 232 + type: 'cal_modify_event', 233 + title: 'Lunch', 234 + newDate: '2026-04-12', 235 + }; 236 + executeCalendarAction(action, deps); 237 + expect(deps.updateEvent).toHaveBeenCalledWith(evt.id, expect.objectContaining({ 238 + date: '2026-04-12', 239 + })); 240 + }); 241 + 242 + it('fails when event not found', () => { 243 + const deps = makeDeps(); 244 + const action: CalendarAction = { 245 + type: 'cal_modify_event', 246 + title: 'Ghost Event', 247 + newTitle: 'X', 248 + }; 249 + const result = executeCalendarAction(action, deps); 250 + expect(result.success).toBe(false); 251 + expect(result.error).toContain('Ghost Event'); 252 + expect(result.error).toContain('not found'); 253 + }); 254 + 255 + it('sets updatedAt timestamp', () => { 256 + const evt = makeEvent({ title: 'Old Event' }); 257 + const deps = makeDeps([evt]); 258 + const before = Date.now(); 259 + executeCalendarAction({ 260 + type: 'cal_modify_event', 261 + title: 'Old Event', 262 + description: 'Updated', 263 + }, deps); 264 + const updateCall = deps.updateEvent.mock.calls[0][1]; 265 + expect(updateCall.updatedAt).toBeGreaterThanOrEqual(before); 266 + }); 267 + }); 268 + 269 + describe('cal_remove_event', () => { 270 + it('removes an event by title', () => { 271 + const evt = makeEvent({ title: 'Obsolete Meeting' }); 272 + const deps = makeDeps([evt]); 273 + const action: CalendarAction = { 274 + type: 'cal_remove_event', 275 + title: 'Obsolete Meeting', 276 + }; 277 + const result = executeCalendarAction(action, deps); 278 + expect(result.success).toBe(true); 279 + expect(deps.removeEvent).toHaveBeenCalledWith(evt.id); 280 + }); 281 + 282 + it('removes event using date disambiguation', () => { 283 + const evt1 = makeEvent({ title: 'Standup', date: '2026-04-10' }); 284 + const evt2 = makeEvent({ title: 'Standup', date: '2026-04-11' }); 285 + const deps = makeDeps([evt1, evt2]); 286 + const action: CalendarAction = { 287 + type: 'cal_remove_event', 288 + title: 'Standup', 289 + date: '2026-04-11', 290 + }; 291 + const result = executeCalendarAction(action, deps); 292 + expect(result.success).toBe(true); 293 + expect(deps.removeEvent).toHaveBeenCalledWith(evt2.id); 294 + }); 295 + 296 + it('fails when event not found', () => { 297 + const deps = makeDeps(); 298 + const action: CalendarAction = { 299 + type: 'cal_remove_event', 300 + title: 'Nonexistent', 301 + }; 302 + const result = executeCalendarAction(action, deps); 303 + expect(result.success).toBe(false); 304 + expect(result.error).toContain('Nonexistent'); 305 + expect(result.error).toContain('not found'); 306 + }); 307 + }); 308 + });