/// /** * BillExtractor Building Block * * A higher-level building block that wraps GmailExtractor to provide * the complete bill tracking data pipeline. Patterns use this for data * processing and provide their own UI rendering. * * ## Key Features * * - Standardized extraction schema (BILL_EXTRACTION_SCHEMA) * - Automatic payment confirmation tracking * - "Likely paid" heuristic for old bills * - Manual mark/unmark paid handlers * - Simple UI components (stats, alerts, demo toggle) * * ## Usage * * ```tsx * import BillExtractor, { * formatCurrency, * formatDate, * formatIdentifier, * getIdentifierColor, * createMarkAsPaidHandler, * createUnmarkAsPaidHandler, * } from "../core/bill-extractor/index.tsx"; * * export default pattern(({ overrideAuth, manuallyPaid, demoMode }) => { * const tracker = BillExtractor({ * gmailQuery: "from:alerts@bank.com", * extractionPrompt: `Analyze this email...`, * identifierType: "card", * title: "My Bank Bill Tracker", * shortName: "MyBank", * brandColor: "#ff0000", * websiteUrl: "https://bank.com", * overrideAuth, * manuallyPaid, * demoMode, * }); * * // Get handlers for use in UI * const markAsPaid = createMarkAsPaidHandler(); * const unmarkAsPaid = createUnmarkAsPaidHandler(); * * return { * [NAME]: "My Bill Tracker", * bills: tracker.bills, * [UI]: ( * * {tracker.ui.summaryStatsUI} * {tracker.unpaidBills.map((bill) => ( *
* {formatCurrency(bill.amount)} * *
* ))} *
* ), * }; * }); * ``` */ import { computed, Default, handler, pattern, Stream, Writable, } from "commontools"; import GmailExtractor from "../gmail-extractor.tsx"; import type { Auth } from "../gmail-extractor.tsx"; import ProcessingStatus from "../processing-status.tsx"; import { BILL_EXTRACTION_SCHEMA, type BillAnalysis, type BillStatus, type TrackedBill, } from "./types.ts"; import { calculateDaysUntilDue, createBillKey, demoPrice, formatCurrency, LIKELY_PAID_THRESHOLD_DAYS, parseDateToMs, } from "./helpers.ts"; // Re-export types and schema for consumers export { BILL_EXTRACTION_SCHEMA } from "./types.ts"; export type { BillAnalysis, BillStatus, TrackedBill } from "./types.ts"; export type { Auth } from "../gmail-extractor.tsx"; // Re-export helpers for use in pattern UI export { formatCurrency, formatDate, formatIdentifier, getIdentifierColor, IDENTIFIER_COLORS, } from "./helpers.ts"; // ============================================================================= // INPUT/OUTPUT TYPES // ============================================================================= /** * Input configuration for BillExtractor. */ export interface BillExtractorInput { /** Gmail search query (e.g., "from:alerts@bankofamerica.com") */ gmailQuery: string; /** * Extraction prompt with {{email.*}} placeholders. * Must instruct LLM what to extract for the "identifier" field. */ extractionPrompt: string; /** * Display format for identifier: * - "card": "...1234" * - "account": "Acct: 1234" */ identifierType: "card" | "account"; /** Full title for header (e.g., "Bank of America Bill Tracker") */ title: string; /** Short name for compact displays (e.g., "BofA") */ shortName: string; /** Brand color for website button (hex) */ brandColor?: Default; /** Provider website URL */ websiteUrl?: string; /** Gmail auth (optional - uses wish() if not provided) */ overrideAuth?: Auth; /** State for persistence - which bills user manually marked as paid */ manuallyPaid?: Writable>; /** Whether to show fake amounts for privacy (demo mode) */ demoMode?: Writable>; /** Maximum number of emails to fetch */ limit?: Default; /** * Whether this provider sends payment confirmation emails. * When false, shows a banner explaining manual tracking is required. * Defaults to true. */ supportsAutoDetect?: Default; } /** * Output from BillExtractor. */ export interface BillExtractorOutput { // Pre-computed data (WARNING: these are building-block scope - don't use in .map() closures with pattern vars) bills: TrackedBill[]; unpaidBills: TrackedBill[]; paidBills: TrackedBill[]; likelyPaidBills: TrackedBill[]; totalUnpaid: number; overdueCount: number; // Raw data for pattern-scope processing (use with processBills() in pattern computed) rawAnalyses: Array<{ emailId: string; emailDate: string; analysis?: { result?: unknown }; }>; paymentConfirmations: Record; // Config (pass-through for patterns to use in UI) identifierType: "card" | "account"; title: string; shortName: string; brandColor: string; websiteUrl?: string; supportsAutoDetect: boolean; // Status pendingCount: number; completedCount: number; emailCount: number; isConnected: boolean; // Operations refresh: Stream; // UI (simple components only - no list iteration) ui: { authStatusUI: JSX.Element; connectionStatusUI: JSX.Element; analysisProgressUI: JSX.Element; previewUI: JSX.Element; summaryStatsUI: JSX.Element; overdueAlertUI: JSX.Element; websiteLinkUI: JSX.Element; /** Banner shown when supportsAutoDetect is false */ manualTrackingBannerUI: JSX.Element; }; // Access to underlying GmailExtractor (for advanced use) gmailExtractor: ReturnType; } // ============================================================================= // HANDLERS (exported for pattern use) // ============================================================================= /** * Create a handler for marking a bill as paid. * Use this in patterns to wire up "Mark Paid" buttons. */ export const createMarkAsPaidHandler = () => handler; bill: TrackedBill }>( (_event, { paidKeys, bill }) => { const current = paidKeys.get() || []; const key = bill.key; if (key && !current.includes(key)) { paidKeys.set([...current, key]); } }, ); /** * Create a handler for unmarking a bill as paid. * Use this in patterns to wire up "Undo" buttons. */ export const createUnmarkAsPaidHandler = () => handler; bill: TrackedBill }>( (_event, { paidKeys, bill }) => { const current = paidKeys.get() || []; const key = bill.key; paidKeys.set(current.filter((k: string) => k !== key)); }, ); // ============================================================================= // BILL PROCESSING (exported for pattern-scope computed) // ============================================================================= /** * Process raw analyses into a bills array. * Call this inside a pattern-scope computed() to avoid closure issues. */ export function processBills( rawAnalyses: ReadonlyArray<{ emailId: string; emailDate: string; analysis?: { result?: BillAnalysis }; }>, paymentConfirmations: Readonly>, manuallyPaidKeys: readonly string[], isDemoMode: boolean, ): TrackedBill[] { const billMap: Record = {}; const today = new Date(); today.setHours(0, 0, 0, 0); const sortedAnalyses = [...(rawAnalyses || [])] .filter((a) => a?.analysis?.result) .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; if (!result) continue; const isBillEmail = result.emailType === "bill_due" || result.emailType === "payment_reminder"; const isStatementWithBillInfo = result.emailType === "statement_ready" && result.dueDate && (result.amount || result.statementBalance); if (!isBillEmail && !isStatementWithBillInfo) continue; if (!result.identifier || !result.dueDate) continue; const billAmount = result.amount || result.statementBalance || 0; const key = createBillKey(result.identifier, result.dueDate); if (billMap[key]) continue; const daysUntilDue = calculateDaysUntilDue(result.dueDate, today); const isManuallyPaid = manuallyPaidKeys.includes(key); const idPayments = paymentConfirmations[result.identifier] || []; const dueDateMs = parseDateToMs(result.dueDate); const matchingPayment = idPayments.find((paymentDate) => { const paymentMs = parseDateToMs(paymentDate); if (isNaN(dueDateMs) || isNaN(paymentMs)) return false; const daysDiff = (paymentMs - dueDateMs) / (1000 * 60 * 60 * 24); return daysDiff >= -30 && daysDiff <= 60; }); const autoPaid = !!matchingPayment; const isLikelyPaid = !isManuallyPaid && !autoPaid && daysUntilDue < LIKELY_PAID_THRESHOLD_DAYS; const isPaid = isManuallyPaid || autoPaid || isLikelyPaid; let status: BillStatus; if (isLikelyPaid) status = "likely_paid"; else if (isPaid) status = "paid"; else if (daysUntilDue < 0) status = "overdue"; else status = "unpaid"; billMap[key] = { key, identifier: result.identifier, amount: demoPrice(billAmount, isDemoMode), dueDate: result.dueDate, status, isPaid, paidDate: matchingPayment || undefined, emailDate: analysisItem.emailDate, emailId: analysisItem.emailId, isManuallyPaid, isLikelyPaid, daysUntilDue, }; } return Object.values(billMap).sort( (a, b) => a.daysUntilDue - b.daysUntilDue, ); } // ============================================================================= // BUILDING BLOCK // ============================================================================= /** * BillExtractor Building Block * * Wraps GmailExtractor and provides the complete bill tracking data pipeline. * Patterns use this for data processing and provide their own UI rendering * for list iteration (which must be at pattern scope for reactive context). */ const BillExtractor = pattern( ({ gmailQuery, extractionPrompt, identifierType, title, shortName, brandColor, websiteUrl, overrideAuth, manuallyPaid, demoMode, limit, supportsAutoDetect, }) => { const resolvedBrandColor = computed(() => brandColor ?? "#3b82f6"); const resolvedSupportsAutoDetect = computed(() => supportsAutoDetect ?? true ); // Use GmailExtractor building block for email fetching and LLM extraction const extractor = GmailExtractor({ gmailQuery, extraction: { schema: BILL_EXTRACTION_SCHEMA, promptTemplate: extractionPrompt, }, title: computed(() => `${shortName} Emails`), resolveInlineImages: false, limit: computed(() => limit ?? 100), overrideAuth, }); // Extract payment confirmations for auto-marking bills as paid const paymentConfirmations = computed(() => { const confirmations: Record> = {}; for (const item of extractor.rawAnalyses || []) { const result = item.analysis?.result as BillAnalysis | undefined; if (!result) continue; if ( result.emailType === "payment_received" && result.identifier && result.paymentDate ) { const key = result.identifier; if (!confirmations[key]) confirmations[key] = new Set(); confirmations[key].add(result.paymentDate); } } const sortedResult: Record = {}; for (const idKey of Object.keys(confirmations).sort()) { sortedResult[idKey] = [...confirmations[idKey]].sort((a, b) => a.localeCompare(b) ); } return sortedResult; }); // Process analyses and build bill list with domain-specific logic const bills = computed(() => { const billMap: Record = {}; // Use .get() to access Writable values inside computed const paidKeys = manuallyPaid?.get() || []; const payments = paymentConfirmations || {}; const isDemoMode = demoMode?.get() ?? true; const today = new Date(); today.setHours(0, 0, 0, 0); const sortedAnalyses = [...(extractor.rawAnalyses || [])] .filter((a) => a?.analysis?.result) .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 | BillAnalysis | undefined; if (!result) continue; const isBillEmail = result.emailType === "bill_due" || result.emailType === "payment_reminder"; const isStatementWithBillInfo = result.emailType === "statement_ready" && result.dueDate && (result.amount || result.statementBalance); if (!isBillEmail && !isStatementWithBillInfo) continue; if (!result.identifier || !result.dueDate) continue; const billAmount = result.amount || result.statementBalance || 0; const key = createBillKey(result.identifier, result.dueDate); if (billMap[key]) continue; const daysUntilDue = calculateDaysUntilDue(result.dueDate, today); const isManuallyPaid = paidKeys.includes(key); const idPayments = payments[result.identifier] || []; const dueDateMs = parseDateToMs(result.dueDate); const matchingPayment = idPayments.find((paymentDate) => { const paymentMs = parseDateToMs(paymentDate); if (isNaN(dueDateMs) || isNaN(paymentMs)) return false; const daysDiff = (paymentMs - dueDateMs) / (1000 * 60 * 60 * 24); return daysDiff >= -30 && daysDiff <= 60; }); const autoPaid = !!matchingPayment; const isLikelyPaid = !isManuallyPaid && !autoPaid && daysUntilDue < LIKELY_PAID_THRESHOLD_DAYS; const isPaid = isManuallyPaid || autoPaid || isLikelyPaid; let status: BillStatus; if (isLikelyPaid) status = "likely_paid"; else if (isPaid) status = "paid"; else if (daysUntilDue < 0) status = "overdue"; else status = "unpaid"; billMap[key] = { key, identifier: result.identifier, amount: demoPrice(billAmount, isDemoMode), dueDate: result.dueDate, status, isPaid, paidDate: matchingPayment || undefined, emailDate: analysisItem.emailDate, emailId: analysisItem.emailId, isManuallyPaid, isLikelyPaid, daysUntilDue, }; } return Object.values(billMap).sort( (a, b) => a.daysUntilDue - b.daysUntilDue, ); }); // Filtered lists const unpaidBills = computed(() => bills.filter((bill) => !bill.isPaid && !bill.isLikelyPaid) ); const likelyPaidBills = computed(() => bills .filter((bill) => bill.isLikelyPaid) .sort((a, b) => b.dueDate.localeCompare(a.dueDate)) ); const paidBills = computed(() => bills .filter((bill) => bill.isPaid && !bill.isLikelyPaid) .sort((a, b) => b.dueDate.localeCompare(a.dueDate)) ); const totalUnpaid = computed(() => unpaidBills.reduce((sum, bill) => sum + bill.amount, 0) ); const overdueCount = computed(() => unpaidBills.filter((bill) => bill.daysUntilDue < 0).length ); // ========================================================================== // SIMPLE UI COMPONENTS (no list iteration) // ========================================================================== // Preview UI for compact display (cards, etc.) const previewUI = (
overdueCount > 0 ? "#fee2e2" : "#eff6ff" ), border: computed(() => overdueCount > 0 ? "2px solid #ef4444" : "2px solid #3b82f6" ), color: computed(() => (overdueCount > 0 ? "#b91c1c" : "#1d4ed8")), display: "flex", alignItems: "center", justifyContent: "center", fontWeight: "bold", fontSize: "16px", }} > {computed(() => unpaidBills?.length || 0)}
{shortName} Bills
{computed(() => formatCurrency(totalUnpaid))} due (overdueCount > 0 ? "inline" : "none")), }} > ({computed(() => overdueCount)} overdue)
); // Summary stats UI const summaryStatsUI = (
{computed(() => formatCurrency(totalUnpaid))}
Total Unpaid
(overdueCount > 0 ? "#dc2626" : "#059669")), }} > {computed(() => unpaidBills?.length || 0)}
Unpaid Bills
(overdueCount > 0 ? "block" : "none")), }} >
{computed(() => overdueCount)}
Overdue
); // Overdue alert UI const overdueAlertUI = (
(overdueCount > 0 ? "block" : "none")), }} >
* {computed(() => overdueCount)} Overdue Bill {computed(() => (overdueCount !== 1 ? "s" : ""))}
Please pay immediately to avoid late fees.
); // Website link UI (hidden when no URL provided) const websiteLinkUI = (
websiteUrl ? "block" : "none"), }} > Open {shortName} Website
); // Pre-compute the threshold days text outside JSX const thresholdDaysText = Math.abs(LIKELY_PAID_THRESHOLD_DAYS); // Manual tracking banner UI (hidden when supportsAutoDetect is true) const manualTrackingBannerUI = (
resolvedSupportsAutoDetect ? "none" : "block" ), }} >
Manual Tracking Required
{shortName}{" "} doesn't send payment confirmation emails. Use "Mark Paid" to track payments, or bills over {thresholdDaysText}{" "} days old will be assumed paid.
); return { // Pre-computed data (building-block scope) bills, unpaidBills, paidBills, likelyPaidBills, totalUnpaid, overdueCount, // Raw data for pattern-scope processing rawAnalyses: extractor.rawAnalyses, paymentConfirmations, // Config identifierType, title, shortName, brandColor: resolvedBrandColor, websiteUrl, supportsAutoDetect: resolvedSupportsAutoDetect, // Status pendingCount: extractor.pendingCount, completedCount: extractor.completedCount, emailCount: extractor.emailCount, isConnected: extractor.isConnected, // Operations refresh: extractor.refresh, // UI (simple components only) ui: { authStatusUI: extractor.ui.authStatusUI, connectionStatusUI: extractor.ui.connectionStatusUI, analysisProgressUI: extractor.ui.analysisProgressUI, previewUI, summaryStatsUI, overdueAlertUI, websiteLinkUI, manualTrackingBannerUI, }, // Access to underlying extractor gmailExtractor: extractor, }; }, ); export default BillExtractor;