Skip to content

src/digest.ts

62 lines · 2.8 KB · TypeScript
Raw
import { parseInput } from './lib/validate.ts'
import { addDays, allTodos, compareTodos, isOverdue, line, localDate, resolveTimeZone } from './lib/store.ts'

/**
 * Daily digest / reminder summary of the open todos in the shared list: overdue, due today, due in the next N days, and undated.
 * Use for the morning list, "what's on my plate", or reminder jobs. Returns concise ready-to-send text.
 *
 * @param input - `{ days?: 1-60 (default 7), includeUndated?: boolean (default true), tag?: string, project?: string, client?: string, timeZone?: string }`
 * @returns `{ today, counts: { overdue, today, upcoming, undated }, sections, text }`
 *
 * @example
 * import digest from 'kody:@charlesmatte/todos/digest'
 *
 * const { text } = await digest({})
 */
export default async function todoDigest(input: unknown = {}) {
	const p = parseInput<{ days?: number; includeUndated?: boolean; tag?: string; project?: string; client?: string; timeZone?: string }>(
		input,
		{
			days: { type: 'integer', min: 1, max: 60 },
			includeUndated: { type: 'boolean' },
			tag: { type: 'string', maxLength: 60 },
			project: { type: 'string', maxLength: 120 },
			client: { type: 'string', maxLength: 120 },
			timeZone: { type: 'string', maxLength: 64 },
		},
		'todos/digest',
	)
	const now = Date.now()
	const tz = await resolveTimeZone(p.timeZone)
	const today = localDate(tz, now)
	const horizon = addDays(today, p.days ?? 7)
	const tag = p.tag?.replace(/^#/, '').toLowerCase()
	const eq = (a: string | null, b: string) => (a ?? '').toLowerCase() === b.toLowerCase()
	const open = (await allTodos())
		.filter((t) => t.status === 'open' && !t.deleted)
		.filter((t) => (!tag || t.tags.includes(tag)) && (!p.project || eq(t.project, p.project)) && (!p.client || eq(t.client, p.client)))
		.sort((a, b) => compareTodos(a, b, tz))
	const overdue = open.filter((t) => isOverdue(t, tz, now))
	const dueToday = open.filter((t) => !isOverdue(t, tz, now) && t.due?.date === today)
	const upcoming = open.filter((t) => t.due && t.due.date > today && t.due.date <= horizon)
	const undated = p.includeUndated === false ? [] : open.filter((t) => !t.due)
	const sec = (title: string, items: typeof open) => (items.length ? `${title} (${items.length})\n${items.map((t) => `- ${line(t, tz, now)}`).join('\n')}` : '')
	const parts = [
		sec('Overdue', overdue),
		sec('Due today', dueToday),
		sec(`Next ${p.days ?? 7} days`, upcoming),
		sec('No due date', undated),
	].filter(Boolean)
	return {
		today,
		timeZone: tz,
		counts: { overdue: overdue.length, today: dueToday.length, upcoming: upcoming.length, undated: undated.length },
		sections: {
			overdue: overdue.map((t) => t.id),
			today: dueToday.map((t) => t.id),
			upcoming: upcoming.map((t) => t.id),
			undated: undated.map((t) => t.id),
		},
		text: parts.length ? `Todos — ${today}\n\n${parts.join('\n\n')}` : `Todos — ${today}: nothing open. 🎉`,
	}
}