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
}