/// /** * Calendar Change Detector Pattern * * Monitors Gmail for last-minute cancellations, reschedules, and schedule changes * for calendar events. Shows urgent UI for changes within 48 hours, and only * displays if there are changes within the next 7 days. * * Features: * - Embeds gmail-importer directly for schedule-change emails * - Extracts change information using LLM from email markdown content * - Calculates urgency based on how soon the event is/was * - Conditional previewUI that only shows when there are relevant changes * * Usage: * 1. Deploy a google-auth piece and complete OAuth * 2. Deploy this pattern * 3. Link: ct piece link google-auth/auth calendar-change-detector/overrideAuth */ import { computed, JSONSchema, NAME, pattern, UI } from "commontools"; import type { Schema } from "commontools/schema"; import GmailExtractor from "../core/gmail-extractor.tsx"; import type { Auth } from "../core/gmail-extractor.tsx"; import ProcessingStatus from "../core/processing-status.tsx"; // ============================================================================= // TYPES // ============================================================================= type ChangeType = | "cancelled" | "rescheduled" | "delayed" | "time_changed" | "other"; type Urgency = "critical" | "urgent" | "normal"; interface ScheduleChange { id: string; emailId: string; emailDate: string; changeType: ChangeType; originalEvent: string; // What was scheduled originalDate?: string; // Original date/time (YYYY-MM-DD or YYYY-MM-DDTHH:mm) newDate?: string; // New date/time if rescheduled source: string; // Calendar service, company, etc. urgency: Urgency; // Critical=today/tomorrow, Urgent=48hrs, Normal=7days summary: string; } // ============================================================================= // CONSTANTS // ============================================================================= /** * Gmail query to find schedule-change emails. * Combines subject keywords AND known sender addresses for maximum coverage. */ const SCHEDULE_CHANGE_GMAIL_QUERY = `(subject:cancelled OR subject:canceled OR subject:rescheduled OR subject:postponed OR subject:"has been changed" OR subject:"time changed" OR subject:"date changed" OR subject:"new date" OR subject:"new time" OR subject:delayed OR subject:"delivery update") OR from:calendar-notification@google.com OR from:notifications@calendly.com OR from:fedex.com OR from:ups.com OR from:amazon.com OR from:notices@library.berkeleypubliclibrary.org`; // Schema for LLM email analysis const SCHEDULE_CHANGE_SCHEMA = { type: "object", properties: { isScheduleChange: { type: "boolean", description: "Whether this email is about a schedule change (cancellation, reschedule, delay, etc.). Set to false for marketing, promotions, unrelated notifications, or spam.", }, changeType: { type: "string", enum: ["cancelled", "rescheduled", "delayed", "time_changed", "other"], description: "Type of schedule change: cancelled for cancellations, rescheduled for date/time changes, delayed for deliveries/appointments pushed back, time_changed for minor time adjustments, other for unclear changes", }, originalEvent: { type: "string", description: "Brief description of what was scheduled (e.g., 'Dentist appointment', 'FedEx package delivery', 'Meeting with John')", }, originalDate: { type: "string", description: "Original scheduled date/time in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:mm). Extract from the email if mentioned.", }, newDate: { type: "string", description: "New scheduled date/time in ISO format if rescheduled. Leave empty if cancelled or not mentioned.", }, source: { type: "string", description: "The service, company, or person who sent the notification (e.g., 'Google Calendar', 'Calendly', 'FedEx', 'Dr. Smith's Office')", }, summary: { type: "string", description: "Brief one-sentence summary of the change (e.g., 'Your dentist appointment on Jan 15 has been cancelled')", }, }, required: ["isScheduleChange", "changeType", "summary"], } as const satisfies JSONSchema; type ScheduleChangeAnalysisResult = Schema; // Prompt template for LLM extraction const EXTRACTION_PROMPT_TEMPLATE = `Analyze this email and determine if it's about a schedule change (cancellation, reschedule, delay, etc.). EMAIL SUBJECT: {{email.subject}} EMAIL DATE: {{email.date}} EMAIL FROM: {{email.from}} EMAIL CONTENT: {{email.markdownContent}} Determine: 1. Is this a legitimate schedule change notification? - YES: Appointment cancellations, meeting reschedules, delivery delays, event time changes - NO: Marketing emails, promotional offers, subscription notices, spam, newsletters, order confirmations without changes 2. If it IS a schedule change: - What type of change is it? (cancelled, rescheduled, delayed, time_changed) - What event/appointment/delivery was affected? - What was the original date/time? (in YYYY-MM-DD or YYYY-MM-DDTHH:mm format) - What is the new date/time if applicable? - Who/what service sent this notification? - Provide a brief summary IMPORTANT: Be strict about filtering. Only mark as a schedule change if it's clearly about an existing appointment, meeting, delivery, or event being modified. Ignore marketing, promotions, and general notifications.`; // ============================================================================= // HELPERS // ============================================================================= /** * Parse a date string to a Date object. * Handles both YYYY-MM-DD and YYYY-MM-DDTHH:mm formats. */ function parseDate(dateStr: string | undefined): Date | null { if (!dateStr) return null; // Try ISO datetime format const dtMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); if (dtMatch) { const [, year, month, day, hour, minute] = dtMatch; return new Date( parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), ); } // Try date-only format const dMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); if (dMatch) { const [, year, month, day] = dMatch; return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } return null; } /** * Calculate days until a date. * Returns negative number for past dates. */ function calculateDaysUntil( dateStr: string | undefined, referenceDate: Date, ): number { const date = parseDate(dateStr); if (!date) return 999; // Normalize to start of day for comparison const ref = new Date(referenceDate); ref.setHours(0, 0, 0, 0); date.setHours(0, 0, 0, 0); return Math.ceil((date.getTime() - ref.getTime()) / (1000 * 60 * 60 * 24)); } /** * Determine urgency based on days until event. */ function calculateUrgency(daysUntil: number): Urgency { if (daysUntil <= 1) return "critical"; // Today or tomorrow if (daysUntil <= 2) return "urgent"; // Within 48 hours return "normal"; // Within 7 days } /** * Format date for display. */ function formatDate(dateStr: string | undefined): string { if (!dateStr) return "N/A"; const date = parseDate(dateStr); if (!date) return dateStr; return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: dateStr.includes("T") ? "numeric" : undefined, minute: dateStr.includes("T") ? "2-digit" : undefined, }); } // ============================================================================= // PATTERN // ============================================================================= interface PatternInput { overrideAuth?: Auth; } /** Calendar change detector for tracking schedule changes. #calendarChanges */ interface PatternOutput { changes: ScheduleChange[]; criticalChanges: ScheduleChange[]; urgentChanges: ScheduleChange[]; normalChanges: ScheduleChange[]; hasChanges: boolean; previewUI: unknown; } export default pattern(({ overrideAuth }) => { // Use GmailExtractor with built-in LLM extraction const extractor = GmailExtractor({ gmailQuery: SCHEDULE_CHANGE_GMAIL_QUERY, extraction: { promptTemplate: EXTRACTION_PROMPT_TEMPLATE, schema: SCHEDULE_CHANGE_SCHEMA, }, title: "Schedule Changes", limit: 50, overrideAuth, }); // Get values from extractor const emailCount = extractor.emailCount; const { pendingCount, completedCount, rawAnalyses } = extractor; // Create emailAnalyses with result alias for backward compatibility const emailAnalyses = computed(() => rawAnalyses?.map((item) => ({ ...item, result: item.analysis?.result as ScheduleChangeAnalysisResult | undefined, })) || [] ); // ========================================================================== // SCHEDULE CHANGE TRACKING // Process analyses and build change list // ========================================================================== const changes = computed(() => { const changeList: ScheduleChange[] = []; // Create a single reference date for ALL calculations const today = new Date(); today.setHours(0, 0, 0, 0); // Seven days from now const sevenDaysFromNow = new Date(today); sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); for (const analysisItem of emailAnalyses || []) { const result = analysisItem.result as | ScheduleChangeAnalysisResult | undefined; if (!result) continue; // Skip if not a schedule change if (!result.isScheduleChange) continue; // Determine the relevant date (original date for cancellations, new date for reschedules) const relevantDate = result.changeType === "cancelled" || result.changeType === "other" ? result.originalDate : result.newDate || result.originalDate; // Calculate days until the relevant date const daysUntil = calculateDaysUntil(relevantDate, today); // Only include changes affecting the next 7 days (or recent past events within 2 days) if (daysUntil > 7 || daysUntil < -2) continue; const urgency = calculateUrgency(daysUntil); const change: ScheduleChange = { id: analysisItem.emailId, emailId: analysisItem.emailId, emailDate: analysisItem.emailDate, changeType: result.changeType as ChangeType, originalEvent: result.originalEvent || "Unknown event", originalDate: result.originalDate, newDate: result.newDate, source: result.source || "Unknown source", urgency, summary: result.summary, }; changeList.push(change); } // Helper to get the relevant date for a change (same logic as urgency calculation) const getRelevantDate = (change: ScheduleChange): string | undefined => { return change.changeType === "cancelled" || change.changeType === "other" ? change.originalDate : change.newDate || change.originalDate; }; // Sort by urgency (critical first) then by relevant date return changeList.sort((a, b) => { const urgencyOrder = { critical: 0, urgent: 1, normal: 2 }; const urgencyDiff = urgencyOrder[a.urgency] - urgencyOrder[b.urgency]; if (urgencyDiff !== 0) return urgencyDiff; // Within same urgency, sort by relevant date (newDate for reschedules, originalDate for cancellations) const dateA = parseDate(getRelevantDate(a))?.getTime() || 0; const dateB = parseDate(getRelevantDate(b))?.getTime() || 0; return dateA - dateB; }); }); // Filter by urgency level const criticalChanges = computed(() => changes.filter((c) => c.urgency === "critical") ); const urgentChanges = computed(() => changes.filter((c) => c.urgency === "urgent") ); const normalChanges = computed(() => changes.filter((c) => c.urgency === "normal") ); // Has any changes const hasChanges = computed(() => changes.length > 0); // Get the most urgent change for preview const mostUrgentChange = computed(() => { if (criticalChanges.length > 0) return criticalChanges[0]; if (urgentChanges.length > 0) return urgentChanges[0]; if (normalChanges.length > 0) return normalChanges[0]; return null; }); // Preview UI - always renders, shows "All clear" when no changes const previewUI = (
{ if (!hasChanges) return "#d1fae5"; // Green for all clear if (criticalChanges.length > 0) return "#fee2e2"; if (urgentChanges.length > 0) return "#fef3c7"; return "#eff6ff"; }), border: computed(() => { if (!hasChanges) return "2px solid #10b981"; if (criticalChanges.length > 0) return "2px solid #ef4444"; if (urgentChanges.length > 0) return "2px solid #f59e0b"; return "2px solid #3b82f6"; }), color: computed(() => { if (!hasChanges) return "#059669"; if (criticalChanges.length > 0) return "#b91c1c"; if (urgentChanges.length > 0) return "#92400e"; return "#1d4ed8"; }), display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold", fontSize: "16px", }} > {computed(() => (hasChanges ? changes?.length || 0 : "✓"))}
{computed(() => { if (!hasChanges) return "Schedule: All Clear"; const critical = criticalChanges?.length || 0; const urgent = urgentChanges?.length || 0; if (critical > 0) { return `${critical} Critical Change${critical !== 1 ? "s" : ""}!`; } if (urgent > 0) { return `${urgent} Urgent Change${urgent !== 1 ? "s" : ""}`; } return `${changes?.length || 0} Schedule Change${ (changes?.length || 0) !== 1 ? "s" : "" }`; })}
{computed(() => { if (!hasChanges) return "No changes in next 7 days"; const change = mostUrgentChange; if (!change) return ""; return `${change.changeType}: ${change.originalEvent}`; })}
{/* Loading/progress indicator */}
); return { [NAME]: "Calendar Change Detector", changes, criticalChanges, urgentChanges, normalChanges, hasChanges, previewUI, [UI]: (
Calendar Change Detector
{/* Auth UI from GmailExtractor */} {extractor.ui.authStatusUI} {/* Connection Status */} {extractor.ui.connectionStatusUI} {/* Analysis Status */} {extractor.ui.analysisProgressUI} {/* Additional analysis info */}
(extractor.isConnected ? "block" : "none"), ), }} >
Results: {computed(() => emailCount)} emails
pendingCount > 0 ? "flex" : "none"), alignItems: "center", gap: "4px", color: "#2563eb", }} > {computed(() => pendingCount)} analyzing...
{computed(() => completedCount)} completed
{/* Summary Stats */}
{ if (criticalChanges.length > 0) return "#dc2626"; if (urgentChanges.length > 0) return "#f59e0b"; return "#3b82f6"; }), }} > {computed(() => changes?.length || 0)}
Total Changes
criticalChanges.length > 0 ? "block" : "none" ), }} >
{computed(() => criticalChanges?.length || 0)}
Critical
urgentChanges.length > 0 ? "block" : "none" ), }} >
{computed(() => urgentChanges?.length || 0)}
Urgent
{/* No Changes Message */}
!hasChanges && completedCount > 0 ? "block" : "none" ), }} >
All clear!
No schedule changes affecting the next 7 days.
{/* Critical Changes Section */}
criticalChanges.length > 0 ? "block" : "none" ), }} >
! Critical - Today/Tomorrow
{criticalChanges.map((change) => (
{change.changeType} {change.source}
{change.originalEvent}
{change.summary}
{computed(() => { if ( change.changeType === "rescheduled" && change.newDate ) { return `${formatDate(change.originalDate)} → ${ formatDate(change.newDate) }`; } return formatDate(change.originalDate); })}
))}
{/* Urgent Changes Section */}
urgentChanges.length > 0 ? "block" : "none" ), }} >
! Urgent - Within 48 Hours
{urgentChanges.map((change) => (
{change.changeType} {change.source}
{change.originalEvent}
{change.summary}
{computed(() => { if ( change.changeType === "rescheduled" && change.newDate ) { return `${formatDate(change.originalDate)} → ${ formatDate(change.newDate) }`; } return formatDate(change.originalDate); })}
))}
{/* Normal Changes Section */}
normalChanges.length > 0 ? "block" : "none" ), }} >
i Upcoming - Within 7 Days
{normalChanges.map((change) => (
{change.changeType} {change.source}
{change.originalEvent}
{change.summary}
{computed(() => { if ( change.changeType === "rescheduled" && change.newDate ) { return `${formatDate(change.originalDate)} → ${ formatDate(change.newDate) }`; } return formatDate(change.originalDate); })}
))}
{/* Debug View Section */}
(emailCount > 0 ? "block" : "none")), }} >
Debug View ({computed(() => emailCount)} emails)

Fetched Emails:

{extractor.emails.map((email) => (
{email.subject}
From: {email.from} | Date: {email.date}
Show content
                            {email.markdownContent}
                          
))}

LLM Analysis Results:

{emailAnalyses.map((analysis) => { const debugResult = analysis.result as | ScheduleChangeAnalysisResult | undefined; return (
analysis.pending ? "1px solid #fbbf24" : analysis.error ? "1px solid #ef4444" : debugResult?.isScheduleChange ? "1px solid #10b981" : "1px solid #d1d5db" ), fontSize: "12px", }} >
{analysis.email.subject}
Analyzing...
Error:{" "} {computed(() => analysis.error ? String(analysis.error) : "" )}
!analysis.pending && !analysis.error && debugResult ? "block" : "none" ), }} >
debugResult?.isScheduleChange ? "#d1fae5" : "#f3f4f6" ), borderRadius: "4px", }} >
Is Schedule Change:{" "} {computed(() => debugResult?.isScheduleChange ? "Yes" : "No" )}
debugResult?.isScheduleChange ? "block" : "none" ), }} > Type:{" "} {computed(() => debugResult?.changeType || "N/A" )}
debugResult?.isScheduleChange ? "block" : "none" ), }} > Event:{" "} {computed(() => debugResult?.originalEvent || "N/A" )}
debugResult?.isScheduleChange ? "block" : "none" ), }} > Original Date:{" "} {computed(() => formatDate(debugResult?.originalDate) )}
Summary:{" "} {computed(() => debugResult?.summary || "N/A")}
); })}
), }; });