Skip to content

src/list.ts

113 lines · 4.1 KB · TypeScript
Raw
import { parseInput } from './lib/validate.ts'
import { ctxField, kindField, priorityField, tzField } from './lib/fields.ts'
import {
	allTodos,
	compareTodos,
	dueInstant,
	isOverdue,
	localDate,
	parseDue,
	summary,
	resolveTimeZone,
	type Kind,
	type Priority,
} from './lib/store.ts'

/**
 * List/search the shared todos and tasks, sorted by due date (undated last) then priority (high first).
 * Use for "what are my todos", "what's due this week", "tasks for project X", or to find an id.
 *
 * @param input - `{ status?: "open"|"done"|"all" (default "open"), overdue?: boolean, today?: boolean (due today in the store zone), dueBefore?: date|datetime (inclusive), dueAfter?: date|datetime (inclusive), undated?: boolean, tag?: string, project?: string, client?: string, kind?: "todo"|"task", priority?: "low"|"normal"|"high", q?: string (text search in title/notes/tags/project/client), includeDeleted?: boolean, limit?: 1-500 (default 100), timeZone?: string }`
 * @returns `{ count, total, todos: [{ id, title, due, priority, ..., overdue, line }], text }` — `text` is ready-to-show lines
 *
 * @example
 * import listTodos from 'kody:@charlesmatte/todos/list'
 *
 * const { text } = await listTodos({ status: 'open', dueBefore: '2026-10-20' })
 */
export default async function listTodos(input: unknown = {}) {
	const p = parseInput<{
		status?: 'open' | 'done' | 'all'
		overdue?: boolean
		today?: boolean
		dueBefore?: string
		dueAfter?: string
		undated?: boolean
		tag?: string
		project?: string
		client?: string
		kind?: Kind
		priority?: Priority
		q?: string
		includeDeleted?: boolean
		limit?: number
		timeZone?: string
	}>(
		input,
		{
			status: { type: 'enum', values: ['open', 'done', 'all'] },
			overdue: { type: 'boolean' },
			today: { type: 'boolean' },
			dueBefore: { type: 'string', maxLength: 40 },
			dueAfter: { type: 'string', maxLength: 40 },
			undated: { type: 'boolean' },
			tag: { type: 'string', maxLength: 60 },
			project: ctxField,
			client: ctxField,
			kind: kindField,
			priority: priorityField,
			q: { type: 'string', maxLength: 200 },
			includeDeleted: { type: 'boolean' },
			limit: { type: 'integer', min: 1, max: 500 },
			timeZone: tzField,
		},
		'todos/list',
	)
	const now = Date.now()
	const tz = await resolveTimeZone(p.timeZone)
	const today = localDate(tz, now)
	const status = p.status ?? 'open'
	// Date-only bounds are inclusive of the whole local day.
	const before = p.dueBefore ? parseDue(p.dueBefore, tz, 'dueBefore') : null
	const after = p.dueAfter ? parseDue(p.dueAfter, tz, 'dueAfter') : null
	const tag = p.tag?.replace(/^#/, '').toLowerCase()
	const q = p.q?.toLowerCase()
	const eq = (a: string | null, b: string) => (a ?? '').toLowerCase() === b.toLowerCase()

	const filtered = (await allTodos()).filter((t) => {
		if (t.deleted && !p.includeDeleted) return false
		if (status !== 'all' && t.status !== status) return false
		if (p.overdue !== undefined && isOverdue(t, tz, now) !== p.overdue) return false
		if (p.today && t.due?.date !== today) return false
		if (p.undated !== undefined && !t.due !== p.undated) return false
		if (before) {
			if (!t.due) return false
			if (before.at ? dueInstant(t.due, tz) > Date.parse(before.at) : t.due.date > before.date) return false
		}
		if (after) {
			if (!t.due) return false
			if (after.at ? dueInstant(t.due, tz) < Date.parse(after.at) : t.due.date < after.date) return false
		}
		if (tag && !t.tags.includes(tag)) return false
		if (p.project && !eq(t.project, p.project)) return false
		if (p.client && !eq(t.client, p.client)) return false
		if (p.kind && t.kind !== p.kind) return false
		if (p.priority && t.priority !== p.priority) return false
		if (q) {
			const hay = [t.title, t.notes, t.project, t.client, t.id, ...t.tags].join(' ').toLowerCase()
			if (!q.split(/\s+/).every((w) => hay.includes(w))) return false
		}
		return true
	})
	filtered.sort((a, b) => compareTodos(a, b, tz))
	const limited = filtered.slice(0, p.limit ?? 100)
	const todos = limited.map((t) => summary(t, tz, now))
	return {
		today,
		timeZone: tz,
		total: filtered.length,
		count: todos.length,
		todos,
		text: todos.length ? todos.map((t) => `- ${t.line}`).join('\n') : 'No matching todos.',
	}
}