import { assertNever, type BriefingInput, type BriefingItem, type BriefingReport, type BriefingSection, type DeliveryPreview, type OutputChannel, type SourceId, isOutputChannel, isSourceId, } from './types.ts' export const DEFAULT_SOURCES: SourceId[] = ['calendar', 'weather'] export const DEFAULT_TIMEZONE = 'UTC' export const DEFAULT_OUTPUT: OutputChannel = 'email' export const DEFAULT_CALENDAR_ACCOUNTS = ['personal'] export const DEFAULT_INBOX_QUERY = 'is:unread newer_than:1d' export const DEFAULT_INBOX_MAX = 8 const CALLOUT_SEVERITIES = new Set(['warning', 'critical']) export function errorMessage(error: unknown): string { if (error instanceof Error && error.message) return error.message return String(error || 'Unknown error') } export function resolveSources(input: BriefingInput = {}): SourceId[] { if (!input.sources) return [...DEFAULT_SOURCES] if (!Array.isArray(input.sources) || input.sources.length === 0) { throw new Error('sources must be a non-empty array of calendar, weather, inbox, health, home') } const resolved: SourceId[] = [] for (const source of input.sources) { if (typeof source !== 'string' || !isSourceId(source)) { throw new Error( `Unknown source "${String(source)}". Pass one of: calendar, weather, inbox, health, home`, ) } if (!resolved.includes(source)) resolved.push(source) } return resolved } export function resolveOutput(input: BriefingInput = {}): OutputChannel { const output = input.output ?? DEFAULT_OUTPUT if (!isOutputChannel(output)) { throw new Error(`Unknown output "${String(output)}". Pass email or notify`) } return output } export function resolveTimezone(input: BriefingInput = {}): string { const timezone = typeof input.timezone === 'string' && input.timezone.trim() ? input.timezone.trim() : DEFAULT_TIMEZONE // Throws RangeError for invalid IANA names so callers fail closed. new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()) return timezone } export function localDate(timezone: string, date = new Date()): string { return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }).format(date) } export function resolveDate(input: BriefingInput = {}, timezone = resolveTimezone(input)): string { if (typeof input.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(input.date)) { return input.date } return localDate(timezone) } export function longDate(date: string, timezone: string): string { const [year, month, day] = date.split('-').map(Number) return new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'long', month: 'long', day: 'numeric', }).format(new Date(Date.UTC(year, month - 1, day, 12))) } export function addDays(date: string, days: number, timezone: string): string { const [year, month, day] = date.split('-').map(Number) const value = new Date(Date.UTC(year, month - 1, day + days, 12)) return localDate(timezone, value) } export function timeZoneOffset(date: string, timezone: string): string { let offset = '+00:00' for (let pass = 0; pass < 2; pass++) { const name = new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'longOffset', }) .formatToParts(new Date(`${date}T00:00:00${offset}`)) .find((part) => part.type === 'timeZoneName')?.value const match = /GMT([+-]\d{2}:\d{2})/.exec(name || '') if (!match || match[1] === offset) break offset = match[1] } return offset } export function dayBounds(date: string, timezone: string): { start: string; end: string } { const end = addDays(date, 1, timezone) return { start: `${date}T00:00:00${timeZoneOffset(date, timezone)}`, end: `${end}T00:00:00${timeZoneOffset(end, timezone)}`, } } export function timeOnly(value: string | undefined, timezone: string): string { if (!value) return '' return new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: '2-digit', }).format(new Date(value)) } export function sourceTitle(source: SourceId): string { switch (source) { case 'calendar': return 'Calendar' case 'weather': return 'Weather' case 'inbox': return 'Inbox' case 'health': return 'Health' case 'home': return 'Home' default: return assertNever(source, 'source') } } export function failedSection(source: SourceId, error: unknown): BriefingSection { return { id: source, title: sourceTitle(source), status: 'error', summary: `${sourceTitle(source)} source failed`, items: [ { title: `${sourceTitle(source)} source failed`, detail: errorMessage(error), severity: 'warning', }, ], } } export function skippedSection(source: SourceId, reason: string): BriefingSection { return { id: source, title: sourceTitle(source), status: 'skipped', summary: reason, items: [ { title: `${sourceTitle(source)} skipped`, detail: reason, severity: 'info', }, ], } } export function calloutsFromSections(sections: BriefingSection[]): BriefingItem[] { return sections.flatMap((section) => section.items.filter((item) => item.severity && CALLOUT_SEVERITIES.has(item.severity)), ) } export function metricsFromSections(sections: BriefingSection[]): BriefingReport['metrics'] { return sections.map((section) => ({ label: section.title, value: section.status === 'ok' ? String(section.items.length) : section.status, detail: section.summary, })) } export function reportMarkdown(report: Pick): string { const lines = [`# ${report.title}`, ''] if (report.callouts.length) { lines.push('## Needs attention', '') for (const item of report.callouts) { lines.push(`- **${item.title}**${item.detail ? `: ${item.detail}` : ''}`) } lines.push('') } for (const section of report.sections) { lines.push(`## ${section.title}`) if (section.summary) lines.push('', section.summary) lines.push('') if (!section.items.length) { lines.push('_Nothing to report._', '') continue } for (const item of section.items) { const link = item.href ? `[${item.title}](${item.href})` : item.title lines.push(`- ${link}${item.detail ? `: ${item.detail}` : ''}`) } lines.push('') } return lines.join('\n').trim() + '\n' } export function notifyBody(report: Pick): string { if (report.callouts.length) { return report.callouts .map((item) => `- ${item.title}${item.detail ? `: ${item.detail}` : ''}`) .join('\n') } const ok = report.sections.filter((section) => section.status === 'ok').length return `${report.title} — ${ok} section${ok === 1 ? '' : 's'} ${ok === 1 ? 'looks' : 'look'} fine. Nothing needs attention.` } export function deliveryPreview(report: BriefingReport, output: OutputChannel): DeliveryPreview { switch (output) { case 'email': return { subject: report.title, text: report.markdown } case 'notify': return { subject: report.callouts.length ? `Briefing: ${report.callouts.length} need attention · ${report.date}` : `Briefing all clear · ${report.date}`, text: report.notifyText, } default: return assertNever(output, 'output') } } export function isDryRun(input: BriefingInput = {}): boolean { return input.dryRun === true }