Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/morning-briefing

src/format.ts

244 lines · 7.1 KB · TypeScript
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<BriefingReport, 'title' | 'callouts' | 'sections'>): 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<BriefingReport, 'title' | 'callouts' | 'sections'>): 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
}