Skip to content

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

Package listing

@kody/morning-briefing

src/report.ts

157 lines · 4.8 KB · TypeScript
import {
	calloutsFromSections,
	deliveryPreview,
	failedSection,
	isDryRun,
	longDate,
	metricsFromSections,
	notifyBody,
	reportMarkdown,
	resolveDate,
	resolveOutput,
	resolveSources,
	resolveTimezone,
} from './format.ts'
import { calendarSection } from './sources/calendar.ts'
import { healthSection } from './sources/health.ts'
import { homeSection } from './sources/home.ts'
import { inboxSection } from './sources/inbox.ts'
import { weatherSection } from './sources/weather.ts'
import { deliverReport } from './output.ts'
import { loadConfig, readReport, saveReport } from './storage.ts'
import {
	assertNever,
	type BriefingInput,
	type BriefingReport,
	type BriefingResult,
	type BriefingSection,
	type SourceId,
} from './types.ts'

const SOURCE_CONCURRENCY = 2

async function runSource(
	source: SourceId,
	input: BriefingInput,
	date: string,
	timezone: string,
): Promise<BriefingSection> {
	switch (source) {
		case 'calendar':
			return await calendarSection(input, date, timezone)
		case 'weather':
			return await weatherSection(input)
		case 'inbox':
			return await inboxSection(input)
		case 'health':
			return await healthSection(input)
		case 'home':
			return await homeSection(input)
		default:
			return assertNever(source, 'source')
	}
}

export async function buildMorningReport(input: BriefingInput = {}): Promise<BriefingReport> {
	const timezone = resolveTimezone(input)
	const date = resolveDate(input, timezone)
	const sources = resolveSources(input)
	const location = typeof input.location === 'string' ? input.location.trim() : undefined

	type Settled =
		| { status: 'fulfilled'; value: BriefingSection; ms: number }
		| { status: 'rejected'; reason: unknown; ms: number }
	const settled: Settled[] = new Array(sources.length)
	let nextIndex = 0

	async function worker() {
		while (nextIndex < sources.length) {
			const index = nextIndex++
			const started = Date.now()
			try {
				const value = await runSource(sources[index], input, date, timezone)
				settled[index] = { status: 'fulfilled', value, ms: Date.now() - started }
			} catch (reason) {
				settled[index] = { status: 'rejected', reason, ms: Date.now() - started }
			}
		}
	}

	await Promise.all(Array.from({ length: Math.min(SOURCE_CONCURRENCY, sources.length) }, worker))

	const sections = settled.map((result, index) =>
		result.status === 'fulfilled' ? result.value : failedSection(sources[index], result.reason),
	)
	const report: BriefingReport = {
		date,
		timezone,
		title: location
			? `${longDate(date, timezone)} · ${location}`
			: longDate(date, timezone),
		generatedAt: new Date().toISOString(),
		location,
		sources,
		metrics: metricsFromSections(sections),
		callouts: calloutsFromSections(sections),
		sections,
		sourceHealth: settled.map((result, index) => ({
			source: sources[index],
			ok: result.status === 'fulfilled',
			detail: result.status === 'rejected' ? String(result.reason instanceof Error ? result.reason.message : result.reason) : undefined,
			ms: result.ms,
		})),
		markdown: '',
		notifyText: '',
	}
	report.markdown = reportMarkdown(report)
	report.notifyText = notifyBody(report)
	return report
}

export async function buildAndStoreReport(input: BriefingInput = {}): Promise<BriefingReport> {
	const report = await buildMorningReport(input)
	await saveReport(report)
	return report
}

/**
 * Build today's briefing from the composed sources and optionally email or notify.
 *
 * @param input.dryRun - Preview subject/body without calling `email_send`.
 * @param input.output - `email` (full briefing) or `notify` (short callouts).
 * @param input.sources - Defaults to calendar + weather. Add inbox, health, home.
 * @example
 * import runMorningBriefing from 'kody:@kody/morning-briefing'
 * const result = await runMorningBriefing({ dryRun: true, location: 'Denver, CO' })
 * // => { date: '2026-08-22', delivered: false, preview: { subject: '...', text: '...' } }
 */
export async function runMorningBriefing(input: BriefingInput = {}): Promise<BriefingResult> {
	const output = resolveOutput(input)
	const report = await buildAndStoreReport(input)
	return await deliverReport({
		report,
		output,
		dryRun: isDryRun(input),
	})
}

/**
 * Scheduled/no-arg wrapper: load stored config, then run the same implementation.
 * Does not send when the stored config sets dryRun: true.
 */
export async function runScheduledMorningBriefing(): Promise<BriefingResult> {
	const config = await loadConfig()
	return await runMorningBriefing(config)
}

export async function getStoredReport(input: BriefingInput = {}): Promise<BriefingReport> {
	const timezone = resolveTimezone(input)
	const date = resolveDate(input, timezone)
	const stored = await readReport(date)
	if (stored) return stored
	return await buildAndStoreReport({ ...input, date, timezone })
}

export function previewDelivery(report: BriefingReport, output: ReturnType<typeof resolveOutput>) {
	return deliveryPreview(report, output)
}