/// /** * Email Ticket Finder Pattern * * Finds upcoming tickets and events in Gmail - flights, concerts, hotel reservations, * and more. Displays them in a dashboard with status indicators. * * Features: * - Uses GmailExtractor building block for email fetching and LLM extraction * - Deduplicates by confirmation code or title+date * - Groups by status: Today, Action Needed, This Week, Later * - Supports multiple ticket types: airline, concert, hotel, etc. * * Usage: * 1. Deploy a google-auth piece and complete OAuth * 2. Deploy this pattern * 3. Link: ct piece link google-auth/auth email-ticket-finder/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"; // Email type - matches GmailImporter's Email type interface Email { id: string; from: string; to: string; subject: string; date: string; snippet: string; threadId: string; labelIds: string[]; htmlContent: string; plainText: string; markdownContent: string; } // ============================================================================= // TYPES // ============================================================================= type TicketSource = | "airline" | "train" | "bus" | "concert" | "sports" | "theater" | "movie" | "hotel" | "rental_car" | "conference" | "workshop" | "restaurant" | "tour" | "other" | "not_a_ticket"; type TicketStatus = "upcoming" | "today" | "past" | "action_needed"; interface ExtractedTicket { isTicket: boolean; ticketSource: TicketSource; eventName: string; eventDate?: string; // ISO format YYYY-MM-DD eventTime?: string; // HH:MM format endDate?: string; // For multi-day events location?: string; venue?: string; confirmationCode?: string; seatInfo?: string; provider?: string; // Airline name, venue name, etc. summary: string; } /** A tracked ticket with calculated status */ interface TrackedTicket { key: string; // Deduplication key eventName: string; ticketSource: TicketSource; eventDate?: string; eventTime?: string; endDate?: string; location?: string; venue?: string; confirmationCode?: string; seatInfo?: string; provider?: string; status: TicketStatus; daysUntil: number; emailId: string; emailDate: string; emailSubject: string; } // ============================================================================= // CONSTANTS // ============================================================================= // Gmail query to find ticket-related emails // Broad search with keywords, LLM will filter out false positives const TICKET_GMAIL_QUERY = `subject:ticket OR subject:"boarding pass" OR subject:e-ticket OR subject:"your reservation" OR subject:"event confirmation" OR subject:"your tickets" OR subject:"order confirmation" OR subject:itinerary OR subject:"flight confirmation" OR subject:"hotel confirmation" OR subject:"booking confirmation"`; // Schema for LLM email analysis const TICKET_ANALYSIS_SCHEMA = { type: "object", properties: { isTicket: { type: "boolean", description: "True ONLY if this email contains a CONFIRMED ticket with a confirmation/booking code. False for promotional emails, invitations to buy tickets, support tickets, lottery tickets, or anything without a clear confirmation code.", }, ticketSource: { type: "string", enum: [ "airline", "train", "bus", "concert", "sports", "theater", "movie", "hotel", "rental_car", "conference", "workshop", "restaurant", "tour", "other", "not_a_ticket", ], description: "The type of ticket or reservation: airline for flights, train/bus for ground transport, concert/sports/theater/movie for entertainment, hotel for accommodations, rental_car for car rentals, conference/workshop for professional events, restaurant for dining reservations, tour for tours/activities, other for misc tickets, not_a_ticket if this is not actually a ticket", }, eventName: { type: "string", description: "Name of the event, flight (e.g., 'Flight to NYC'), show, hotel stay, etc.", }, eventDate: { type: "string", description: "Event/travel date in YYYY-MM-DD format. For flights, use departure date.", }, eventTime: { type: "string", description: "Event/departure time in HH:MM format (24-hour). For flights, use departure time.", }, endDate: { type: "string", description: "End date in YYYY-MM-DD format for multi-day events (hotel checkout, return flight, etc.)", }, location: { type: "string", description: "Location/destination. For flights: arrival city. For hotels: city. For concerts: city.", }, venue: { type: "string", description: "Specific venue name (stadium, theater, hotel name, airport, etc.)", }, confirmationCode: { type: "string", description: "Confirmation/booking/reference code/number (very important for deduplication)", }, seatInfo: { type: "string", description: "Seat assignment, section, row, or similar positioning info", }, provider: { type: "string", description: "Service provider name (airline, hotel chain, ticketing company, etc.)", }, summary: { type: "string", description: "Brief one-sentence summary of what this ticket/reservation is for", }, }, required: ["isTicket", "ticketSource", "eventName", "summary"], } as const satisfies JSONSchema; type TicketAnalysisResult = Schema; const EXTRACTION_PROMPT_TEMPLATE = `Analyze this email and determine if it contains an ACTUAL CONFIRMED ticket or reservation. CRITICAL DISTINCTION - isTicket=true ONLY for CONFIRMED tickets with: - A confirmation/booking/reference code - A specific date and time for the event - Clear indication that a purchase/booking was completed CONFIRMED TICKETS (isTicket=true): - Flight tickets with PNR/confirmation code (e.g., "Your confirmation: ABC123") - Train/bus tickets with booking reference - Concert/sports/theater tickets that were PURCHASED (with order number) - Hotel reservations with confirmation number - Car rental confirmations with reservation number - Conference registrations with registration ID - Restaurant reservations with confirmation NOT TICKETS (isTicket=false): - Promotional emails ("Get your tickets!", "Buy now!", "Don't miss out!") - Event announcements or invitations without a confirmed purchase - Emails asking you to RSVP or register (not yet confirmed) - Support tickets / help desk tickets - Lottery tickets / sweepstakes - Parking tickets / violations - Order confirmations for physical goods (not events) - Newsletters, marketing emails, or reminders to buy KEY RULE: If there's no confirmation code and no clear indication of a completed purchase, it's NOT a confirmed ticket. EMAIL SUBJECT: {{email.subject}} EMAIL DATE: {{email.date}} EMAIL FROM: {{email.from}} EMAIL CONTENT: {{email.markdownContent}} Extract: 1. Is this a CONFIRMED ticket/reservation with a confirmation code? (true/false) 2. Type of ticket (airline, concert, hotel, etc.) 3. Event name (flight route, show name, hotel name, etc.) 4. Event date in YYYY-MM-DD format (MUST be a valid future or recent date) 5. Event time in HH:MM format (if available) 6. End date for multi-day events (hotel checkout, etc.) 7. Location/destination 8. Venue name 9. Confirmation/booking code (REQUIRED for isTicket=true) 10. Seat info if available 11. Provider/company name 12. Brief summary`; // ============================================================================= // HELPERS // ============================================================================= /** * Create a deduplication key for a ticket. * Uses confirmation code if available, otherwise title+date. */ function createTicketKey(ticket: ExtractedTicket): string { if (ticket.confirmationCode) { return `conf:${ticket.confirmationCode.toLowerCase().trim()}`; } const name = (ticket.eventName || "").toLowerCase().trim(); const date = ticket.eventDate || ""; return `${name}|${date}`; } /** * Calculate days until event date. * Returns negative number for past events. */ function calculateDaysUntil( eventDate: string | undefined, referenceDate: Date, ): number { if (!eventDate) return 999; // No date = far in future const match = eventDate.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!match) return 999; const [, year, month, day] = match; const event = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); if (isNaN(event.getTime())) return 999; event.setHours(0, 0, 0, 0); return Math.ceil( (event.getTime() - referenceDate.getTime()) / (1000 * 60 * 60 * 24), ); } /** * Determine ticket status based on days until event. */ function calculateStatus(daysUntil: number): TicketStatus { if (daysUntil < 0) return "past"; if (daysUntil === 0) return "today"; // Could add "action_needed" logic here based on ticket type // e.g., flights within 24 hours might need check-in return "upcoming"; } /** * Validate and parse a date string in YYYY-MM-DD format. * Returns null if invalid. */ function parseValidDate(dateStr: string | undefined): Date | null { if (!dateStr) return null; const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!match) return null; const [, year, month, day] = match; const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); if (isNaN(date.getTime())) return null; return date; } /** * Format date for display. */ function formatDate(dateStr: string | undefined): string { if (!dateStr) return "Date TBD"; const date = parseValidDate(dateStr); if (!date) return "Date TBD"; return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", }); } /** * Get icon for ticket source. */ function getTicketIcon(source: TicketSource): string { switch (source) { case "airline": return "โœˆ๏ธ"; case "train": return "๐Ÿš†"; case "bus": return "๐ŸšŒ"; case "concert": return "๐ŸŽต"; case "sports": return "๐ŸŸ๏ธ"; case "theater": return "๐ŸŽญ"; case "movie": return "๐ŸŽฌ"; case "hotel": return "๐Ÿจ"; case "rental_car": return "๐Ÿš—"; case "conference": return "๐Ÿ“‹"; case "workshop": return "๐ŸŽ“"; case "restaurant": return "๐Ÿฝ๏ธ"; case "tour": return "๐ŸŽก"; default: return "๐ŸŽซ"; } } /** * Get status color styling. */ function getStatusColor(status: TicketStatus): { bg: string; border: string; text: string; } { switch (status) { case "today": return { bg: "#fef3c7", border: "#f59e0b", text: "#b45309" }; case "action_needed": return { bg: "#fee2e2", border: "#ef4444", text: "#b91c1c" }; case "upcoming": return { bg: "#d1fae5", border: "#10b981", text: "#047857" }; case "past": return { bg: "#f3f4f6", border: "#d1d5db", text: "#6b7280" }; default: return { bg: "#f3f4f6", border: "#d1d5db", text: "#4b5563" }; } } /** * Get status label for display. */ function getStatusLabel(status: TicketStatus, daysUntil: number): string { switch (status) { case "today": return "Today"; case "action_needed": return "Action Needed"; case "upcoming": if (daysUntil === 1) return "Tomorrow"; if (daysUntil <= 7) return `In ${daysUntil} days`; return `${daysUntil} days away`; case "past": if (daysUntil === -1) return "Yesterday"; return `${Math.abs(daysUntil)} days ago`; default: return ""; } } // ============================================================================= // PATTERN // ============================================================================= interface PatternInput { overrideAuth?: Auth; // No additional writable state needed for this pattern // (could add dismissed/hidden tickets later) } /** Email ticket finder for tracking upcoming events. #emailTickets */ interface PatternOutput { tickets: TrackedTicket[]; todayTickets: TrackedTicket[]; upcomingTickets: TrackedTicket[]; pastTickets: TrackedTicket[]; todayCount: number; upcomingCount: number; previewUI: unknown; } export default pattern(({ overrideAuth }) => { // Use GmailExtractor building block for email fetching and LLM extraction const extractor = GmailExtractor({ gmailQuery: TICKET_GMAIL_QUERY, extraction: { promptTemplate: EXTRACTION_PROMPT_TEMPLATE, schema: TICKET_ANALYSIS_SCHEMA, }, title: "Ticket Emails", resolveInlineImages: false, limit: 100, overrideAuth, }); // Convenience aliases from extractor const { rawAnalyses, emailCount, pendingCount, completedCount } = extractor; // ========================================================================== // TICKET TRACKING // Build deduplicated list of tickets // ========================================================================== const tickets = computed(() => { const ticketMap = new Map(); // Create a single reference date for deterministic calculations const today = new Date(); today.setHours(0, 0, 0, 0); // Sort emails by date (newest first) so we keep most recent data const sortedAnalyses = [...(rawAnalyses || [])] .filter((a) => (a?.analysis?.result as TicketAnalysisResult | undefined)?.isTicket ) .sort((a, b) => { const dateA = new Date(a.emailDate || 0).getTime(); const dateB = new Date(b.emailDate || 0).getTime(); if (dateB !== dateA) return dateB - dateA; return (a.emailId || "").localeCompare(b.emailId || ""); }); for (const analysisItem of sortedAnalyses) { const result = analysisItem.analysis?.result as | TicketAnalysisResult | undefined; if (!result || !result.isTicket) continue; if (result.ticketSource === "not_a_ticket") continue; // Skip tickets without a valid date (likely promotional emails that slipped through) const eventDate = parseValidDate(result.eventDate); if (!eventDate) continue; const key = createTicketKey(result); // Skip if we already have this ticket (we process newest first) if (ticketMap.has(key)) continue; const daysUntil = calculateDaysUntil(result.eventDate, today); const status = calculateStatus(daysUntil); const trackedTicket: TrackedTicket = { key, eventName: result.eventName, ticketSource: result.ticketSource, eventDate: result.eventDate, eventTime: result.eventTime, endDate: result.endDate, location: result.location, venue: result.venue, confirmationCode: result.confirmationCode, seatInfo: result.seatInfo, provider: result.provider, status, daysUntil, emailId: analysisItem.emailId, emailDate: analysisItem.emailDate, emailSubject: analysisItem.email?.subject || "", }; ticketMap.set(key, trackedTicket); } // Convert to array and sort by event date (soonest first) const items = Array.from(ticketMap.values()); return items.sort((a, b) => { // Sort by days until (ascending), with today/upcoming before past if (a.status === "past" && b.status !== "past") return 1; if (a.status !== "past" && b.status === "past") return -1; return a.daysUntil - b.daysUntil; }); }); // Filter by status const todayTickets = computed(() => (tickets || []).filter((t) => t.status === "today") ); const upcomingTickets = computed(() => (tickets || []).filter((t) => t.status === "upcoming") ); const pastTickets = computed(() => (tickets || []).filter((t) => t.status === "past") ); // Counts const todayCount = computed(() => todayTickets?.length || 0); const upcomingCount = computed(() => upcomingTickets?.length || 0); // Next event for preview const nextTicket = computed(() => { const upcoming = [...(todayTickets || []), ...(upcomingTickets || [])]; return upcoming[0] || null; }); // Preview UI for compact display const previewUI = (
todayCount > 0 ? "#fef3c7" : "#d1fae5" ), border: computed(() => todayCount > 0 ? "2px solid #f59e0b" : "2px solid #10b981" ), color: computed(() => (todayCount > 0 ? "#b45309" : "#047857")), display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold", fontSize: "16px", }} > {computed(() => todayCount + upcomingCount)}
Upcoming Tickets
(todayCount > 0 ? "inline" : "none")), }} > {todayCount} today todayCount > 0 && upcomingCount > 0 ? "inline" : "none" ), }} > {" ยท "} (upcomingCount > 0 ? "inline" : "none")), }} > {upcomingCount} upcoming nextTicket ? "inline" : "none"), }} > {" - "} {computed(() => nextTicket?.eventName || "")}
{/* Loading/progress indicator */}
); return { [NAME]: "Email Tickets", tickets, todayTickets, upcomingTickets, pastTickets, todayCount, upcomingCount, previewUI, [UI]: (
Email Ticket Finder
{/* Auth UI from GmailExtractor */} {extractor.ui.authStatusUI} {/* Connection Status */} {extractor.ui.connectionStatusUI} {/* Analysis Status */} {extractor.ui.analysisProgressUI} {/* Summary Stats */}
{todayCount}
Today
{upcomingCount}
Upcoming
{computed(() => (tickets || []).length)}
Total
{/* Today's Events Alert */}
(todayCount > 0 ? "block" : "none")), }} >
๐ŸŽซ {todayCount} Event{todayCount !== 1 ? "s" : ""} Today!
{todayTickets.map((ticket) => (
{getTicketIcon(ticket.ticketSource)}
{ticket.eventName}
{ticket.eventTime || ""}{" "} {ticket.venue || ticket.location || ""}
{ticket.confirmationCode}
))}
{/* Upcoming Events Section */}
upcomingCount > 0 ? "block" : "none"), }} >

Upcoming Events

{upcomingTickets.map((ticket) => { const statusColors = getStatusColor(ticket.status); return (
{getTicketIcon(ticket.ticketSource)}
{ticket.eventName} {getStatusLabel(ticket.status, ticket.daysUntil)}
{formatDate(ticket.eventDate)} {ticket.eventTime ? ` at ${ticket.eventTime}` : ""}
๐Ÿ“ {ticket.venue || ""}{" "} {ticket.venue && ticket.location ? " - " : ""} {ticket.location || ""}
{ticket.provider}
Conf: {ticket.confirmationCode}
{ticket.seatInfo}
); })}
{/* Past Events Section */}
(pastTickets || []).length > 0 ? "block" : "none" ), }} >
Past Events ({computed(() => (pastTickets || []).length)}) {pastTickets.map((ticket) => (
{getTicketIcon(ticket.ticketSource)}
{ticket.eventName}
{formatDate(ticket.eventDate)} ยท{" "} {getStatusLabel(ticket.status, ticket.daysUntil)}
))}
{/* Debug View Section */}
(emailCount > 0 ? "block" : "none")), }} >
Debug View ({emailCount} emails analyzed)

LLM Analysis Results:

{rawAnalyses.map((analysisItem) => { const debugResult = analysisItem.analysis?.result as | TicketAnalysisResult | undefined; return (
analysisItem.pending ? "1px solid #fbbf24" : analysisItem.error ? "1px solid #ef4444" : debugResult?.isTicket ? "1px solid #10b981" : "1px solid #d1d5db" ), fontSize: "12px", }} >
{analysisItem.email.subject}
Analyzing...
Error: {computed(() => analysisItem.error ? String(analysisItem.error) : "" )}
!analysisItem.pending && !analysisItem.error && debugResult ? "block" : "none" ), }} >
debugResult?.isTicket ? "#d1fae5" : "#f3f4f6" ), borderRadius: "4px", }} >
Is Ticket:{" "} {computed(() => debugResult?.isTicket ? "Yes โœ“" : "No" )}
debugResult?.isTicket ? "block" : "none" ), }} > Type: {computed( () => debugResult?.ticketSource || "N/A", )}
debugResult?.isTicket ? "block" : "none" ), }} > Event: {computed( () => debugResult?.eventName || "N/A", )}
debugResult?.eventDate ? "block" : "none" ), }} > Date: {computed(() => formatDate( debugResult?.eventDate, ) )}
debugResult?.confirmationCode ? "block" : "none" ), }} > Confirmation: {computed( () => debugResult?.confirmationCode || "", )}
Summary: {computed( () => debugResult?.summary || "N/A", )}
); })}
), }; });