Skip to content

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

Package listing

@kody/morning-briefing

src/sources/calendar.ts

132 lines · 3.6 KB · TypeScript
import { listEventsAcrossCalendars } from 'kody:@kody/google/calendar'
import { dayBounds, skippedSection, timeOnly } from '../format.ts'
import { googleCalendarSetupError } from '../google-setup.ts'
import type { BriefingInput, BriefingItem, BriefingSection } from '../types.ts'

type CalendarDateTime = { date?: string; dateTime?: string }

type CalendarEvent = {
	summary?: string
	start?: CalendarDateTime
	end?: CalendarDateTime
	htmlLink?: string
	recurringEventId?: string
	recurrence?: string[]
	calendarSummary?: string
	calendarId?: string
	accountLabel?: string
}

type CalendarListResult = {
	items?: CalendarEvent[]
	calendars?: unknown[]
	failures?: string[]
}

function isRecurring(event: CalendarEvent): boolean {
	return Boolean(event.recurringEventId) || Boolean(event.recurrence?.length)
}

function eventSortKey(event: CalendarEvent): string {
	return event.start?.dateTime || event.start?.date || ''
}

function formatWhen(event: CalendarEvent, timezone: string): string {
	if (event.start?.date) return 'All day'
	const start = timeOnly(event.start?.dateTime, timezone)
	const end = timeOnly(event.end?.dateTime, timezone)
	return end ? `${start}–${end}` : start
}

export async function calendarSection(
	input: BriefingInput,
	date: string,
	timezone: string,
): Promise<BriefingSection> {
	const accounts = input.calendar?.accounts?.length
		? input.calendar.accounts.map((account) => String(account).trim()).filter(Boolean)
		: ['personal']
	if (!accounts.length) {
		return skippedSection(
			'calendar',
			'Pass calendar.accounts (for example ["personal"]) after connecting Google OAuth.',
		)
	}

	const { start, end } = dayBounds(date, timezone)
	const events: CalendarEvent[] = []
	const failures: string[] = []

	const results = await Promise.all(
		accounts.map(async (account) => {
			try {
				const result = (await listEventsAcrossCalendars({
					account,
					timeMin: start,
					timeMax: end,
					singleEvents: true,
					orderBy: 'startTime',
					maxResults: 100,
					calendarMaxResults: 100,
					showHiddenCalendars: true,
				})) as CalendarListResult
				return { account, result, error: null as unknown }
			} catch (error) {
				return { account, result: null, error: googleCalendarSetupError(error, account) }
			}
		}),
	)

	for (const { account, result, error } of results) {
		if (!result) {
			failures.push(error instanceof Error ? error.message : String(error))
			continue
		}
		for (const failure of result.failures || []) {
			failures.push(`${account}: ${failure}`)
		}
		for (const event of result.items || []) {
			events.push({ ...event, accountLabel: account })
		}
	}

	const unusual = events
		.filter((event) => !isRecurring(event))
		.sort((a, b) => eventSortKey(a).localeCompare(eventSortKey(b)))

	const items: BriefingItem[] = unusual.slice(0, 10).map((event) => ({
		title: event.summary || 'Calendar event',
		detail: `${event.accountLabel || 'calendar'} · ${event.calendarSummary || 'Primary'} · ${formatWhen(event, timezone)}`,
		label: event.start?.date ? 'all-day' : timeOnly(event.start?.dateTime, timezone),
		severity: 'notice',
		href: event.htmlLink,
	}))

	for (const failure of failures) {
		items.push({
			title: 'Calendar source warning',
			detail: failure,
			severity: 'warning',
		})
	}

	const status = failures.length && !unusual.length
		? 'warning'
		: unusual.length
			? 'ok'
			: failures.length
				? 'warning'
				: 'empty'

	return {
		id: 'calendar',
		title: 'Calendar',
		status,
		summary: unusual.length
			? `${unusual.length} non-recurring event${unusual.length === 1 ? '' : 's'} today`
			: failures.length
				? 'Calendar auth or listing failed'
				: 'No non-recurring events today',
		items,
	}
}