Skip to content

src/update.ts

130 lines · 4.9 KB · TypeScript
Raw
import { InputError, parseInput } from './lib/validate.ts'
import { byField, tzField, dueField, idField, kindField, notesField, priorityField, tagsField, titleField } from './lib/fields.ts'
import {
	addDays,
	dueInstant,
	line,
	localDate,
	mutateTodo,
	normalizeId,
	normalizeTags,
	parseDue,
	resolveTimeZone,
	type Due,
	type Kind,
	type Priority,
} from './lib/store.ts'

/**
 * Edit a todo/task in the shared todo list: change any field, add/remove tags, clear the due date, or snooze it.
 * Pass only the fields to change. To clear: `clearDue: true`, `notes: ""`, `project: ""`, `client: ""`.
 *
 * @param input - `{ id: string, by: string, title?, notes?, due?: date|datetime|"today"|"tomorrow", clearDue?: boolean, snooze?: { days?: int, hours?: int, until?: date|datetime }, priority?, kind?, tags?: string[] (replace), addTags?: string[], removeTags?: string[], project?: string, client?: string, timeZone?: string }`
 * @returns `{ todo, line, changes }`
 *
 * @example
 * import updateTodo from 'kody:@charlesmatte/todos/update'
 *
 * await updateTodo({ id: 'T12', by: 'assistant', priority: 'high', snooze: { days: 2 } })
 */
export default async function updateTodo(input: unknown) {
	const p = parseInput<{
		id: string
		by: string
		title?: string
		notes?: string
		due?: string
		clearDue?: boolean
		snooze?: Record<string, unknown>
		priority?: Priority
		kind?: Kind
		tags?: Array<string>
		addTags?: Array<string>
		removeTags?: Array<string>
		project?: string
		client?: string
		timeZone?: string
	}>(
		input,
		{
			id: idField,
			by: byField,
			title: titleField,
			notes: notesField,
			due: dueField,
			clearDue: { type: 'boolean' },
			snooze: { type: 'object' },
			priority: priorityField,
			kind: kindField,
			tags: tagsField,
			addTags: tagsField,
			removeTags: tagsField,
			project: { type: 'string', maxLength: 120, allowEmpty: true },
			client: { type: 'string', maxLength: 120, allowEmpty: true },
			timeZone: tzField,
		},
		'todos/update',
	)
	const dueModes = [p.due !== undefined, p.clearDue === true, p.snooze !== undefined].filter(Boolean).length
	if (dueModes > 1) throw new InputError('todos/update: pass only one of "due", "clearDue", "snooze".')
	if (p.tags && (p.addTags || p.removeTags)) throw new InputError('todos/update: use "tags" (replace) or "addTags"/"removeTags", not both.')

	const tz = await resolveTimeZone(p.timeZone)
	let snoozeFn: ((cur: Due | null) => Due) | null = null
	if (p.snooze) {
		const s = parseInput<{ days?: number; hours?: number; until?: string }>(
			p.snooze,
			{ days: { type: 'integer', min: 1, max: 365 }, hours: { type: 'integer', min: 1, max: 24 * 30 }, until: dueField },
			'todos/update snooze',
		)
		const n = [s.days, s.hours, s.until].filter((v) => v !== undefined).length
		if (n !== 1) throw new InputError('todos/update: "snooze" needs exactly one of { days: int } | { hours: int } | { until: date|datetime }.')
		if (s.until) {
			const d = parseDue(s.until, tz, 'snooze.until')
			snoozeFn = () => d
		} else if (s.days) {
			const days = s.days
			// Shift from the current due (or from today if undated/overdue) by N days, keeping the time of day.
			snoozeFn = (cur) => {
				const today = localDate(tz)
				const base = cur && cur.date >= today ? cur.date : today
				const date = addDays(base, days)
				return cur?.time ? parseDue(`${date}T${cur.time}`, cur.timeZone ?? tz) : { date, at: null, time: null, timeZone: tz }
			}
		} else {
			const hours = s.hours!
			snoozeFn = (cur) => {
				const base = Math.max(Date.now(), cur ? dueInstant(cur, tz) : 0)
				return parseDue(new Date(base + hours * 3600_000).toISOString(), tz)
			}
		}
	}
	const newDue = p.due !== undefined ? parseDue(p.due, tz) : undefined
	const action = p.snooze ? 'snoozed' : 'updated'
	const todo = await mutateTodo(normalizeId(p.id), p.by, action, (t) => {
		const changes: Record<string, unknown> = {}
		const set = <K extends keyof typeof t>(k: K, v: (typeof t)[K]) => {
			if (JSON.stringify(t[k]) !== JSON.stringify(v)) {
				changes[k as string] = { from: t[k], to: v }
				t[k] = v
			}
		}
		if (p.title !== undefined) set('title', p.title)
		if (p.notes !== undefined) set('notes', p.notes.trim() ? p.notes.trim() : null)
		if (newDue !== undefined) set('due', newDue)
		if (p.clearDue) set('due', null)
		if (snoozeFn) set('due', snoozeFn(t.due))
		if (p.priority) set('priority', p.priority)
		if (p.kind) set('kind', p.kind)
		if (p.tags) set('tags', normalizeTags(p.tags))
		if (p.addTags || p.removeTags) {
			const rm = new Set(normalizeTags(p.removeTags ?? []))
			set('tags', normalizeTags([...t.tags, ...(p.addTags ?? [])]).filter((x) => !rm.has(x)))
		}
		if (p.project !== undefined) set('project', p.project.trim() || null)
		if (p.client !== undefined) set('client', p.client.trim() || null)
		if (Object.keys(changes).length === 0) throw new InputError('todos/update: nothing to change (all values already match).')
		return changes
	})
	return { todo, line: line(todo, tz), changes: todo.history[todo.history.length - 1]?.changes ?? {} }
}