Skip to content

src/lib/store.ts

347 lines · 12.0 KB · TypeScript
Raw
import { packageStorage } from 'kody:runtime'
import { InputError } from './validate.ts'

export const DEFAULT_TIME_ZONE = 'UTC'
export const PRIORITIES = ['low', 'normal', 'high'] as const
export const KINDS = ['todo', 'task'] as const
export type Priority = (typeof PRIORITIES)[number]
export type Kind = (typeof KINDS)[number]

export type Due = {
	/** Local calendar date (YYYY-MM-DD) in `timeZone`. */
	date: string
	/** Exact instant (UTC ISO) when a time was given; null for all-day items. */
	at: string | null
	/** Local wall-clock time HH:MM in `timeZone`, or null for all-day. */
	time: string | null
	/** IANA time zone the date/time were resolved in. */
	timeZone?: string
}

export type HistoryEntry = { at: string; by: string; action: string; changes?: Record<string, unknown> }

export type Todo = {
	id: string
	kind: Kind
	title: string
	notes: string | null
	status: 'open' | 'done'
	deleted: boolean
	priority: Priority
	tags: Array<string>
	project: string | null
	client: string | null
	due: Due | null
	createdAt: string
	updatedAt: string
	completedAt: string | null
	deletedAt: string | null
	createdBy: string
	updatedBy: string
	version: number
	history: Array<HistoryEntry>
}

type Store = ReturnType<typeof packageStorage>
const TABLE = 'todos'
let ready: Promise<void> | null = null

function store(): Store {
	try {
		return packageStorage()
	} catch (error) {
		throw new Error(
			'The todo store lives in this package\'s packageStorage() and is only reachable through a static ' +
				"`import x from 'kody:@<owner>/todos/<export>'`. " +
				`Underlying error: ${error instanceof Error ? error.message : String(error)}`,
		)
	}
}

function rowsOf(result: unknown): Array<Record<string, unknown>> {
	if (result && typeof result === 'object' && Array.isArray((result as { rows?: unknown }).rows)) {
		return (result as { rows: Array<Record<string, unknown>> }).rows
	}
	return []
}

async function db(): Promise<Store> {
	const s = store()
	if (!ready) {
		ready = (async () => {
			await s.sql(
				`CREATE TABLE IF NOT EXISTS ${TABLE} (id TEXT PRIMARY KEY, seq INTEGER NOT NULL, version INTEGER NOT NULL, json TEXT NOT NULL, updated_at TEXT NOT NULL)`,
				[],
			)
			await s.sql(`CREATE TABLE IF NOT EXISTS todo_meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`, [])
			await s.sql(`INSERT OR IGNORE INTO todo_meta (key, value) VALUES ('seq', 0)`, [])
			await s.sql(`CREATE TABLE IF NOT EXISTS todo_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, [])
		})().catch((e) => {
			ready = null
			throw e
		})
	}
	await ready
	return s
}

// ---------- settings ----------

export function validTimeZone(tz: string): string {
	try {
		return new Intl.DateTimeFormat('en-US', { timeZone: tz }).resolvedOptions().timeZone
	} catch {
		throw new InputError(`"timeZone": ${JSON.stringify(tz)} is not a valid IANA time zone (e.g. "America/Toronto", "Europe/Paris", "UTC").`)
	}
}

export async function getSettings(): Promise<{ timeZone: string; configured: boolean }> {
	const s = await db()
	const row = rowsOf(await s.sql(`SELECT value FROM todo_settings WHERE key = 'timeZone'`, []))[0]
	return row ? { timeZone: String(row.value), configured: true } : { timeZone: DEFAULT_TIME_ZONE, configured: false }
}

export async function setTimeZone(tz: string): Promise<void> {
	const s = await db()
	await s.sql(`INSERT INTO todo_settings (key, value) VALUES ('timeZone', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [tz])
}

/** Resolve the zone for a call: explicit per-call override, else the store setting, else UTC. */
export async function resolveTimeZone(override?: string): Promise<string> {
	if (override) return validTimeZone(override)
	return (await getSettings()).timeZone
}

// ---------- time helpers ----------

function zonedParts(ms: number, tz: string) {
	const fmt = new Intl.DateTimeFormat('en-CA', {
		timeZone: tz,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
		hour: '2-digit',
		minute: '2-digit',
		second: '2-digit',
		hourCycle: 'h23',
	})
	const p: Record<string, string> = {}
	for (const part of fmt.formatToParts(new Date(ms))) p[part.type] = part.value
	return p
}

export function localDate(tz: string, ms: number = Date.now()): string {
	const p = zonedParts(ms, tz)
	return `${p.year}-${p.month}-${p.day}`
}

function localTime(ms: number, tz: string): string {
	const p = zonedParts(ms, tz)
	return `${p.hour}:${p.minute}`
}

function offsetMs(ms: number, tz: string): number {
	const p = zonedParts(ms, tz)
	const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second)
	return asUtc - Math.floor(ms / 1000) * 1000
}

/** Convert a wall-clock time in `tz` to a UTC epoch. */
export function zonedToUtc(tz: string, y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number {
	const guess = Date.UTC(y, mo - 1, d, h, mi, s)
	let utc = guess - offsetMs(guess, tz)
	utc = guess - offsetMs(utc, tz)
	return utc
}

export function addDays(date: string, days: number): string {
	const [y, m, d] = date.split('-').map(Number)
	const t = new Date(Date.UTC(y, m - 1, d + days))
	return t.toISOString().slice(0, 10)
}

const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const LOCAL_DT_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/
const ZONED_DT_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/

export const DUE_HELP =
	'"YYYY-MM-DD" (all-day), "YYYY-MM-DDTHH:MM" (wall clock in the store time zone), an ISO datetime with Z/offset, ' +
	'or one of "today" | "tomorrow"'

export function parseDue(raw: string, tz: string, field = 'due'): Due {
	const v = raw.trim()
	const lower = v.toLowerCase()
	if (lower === 'today') return { date: localDate(tz), at: null, time: null, timeZone: tz }
	if (lower === 'tomorrow') return { date: addDays(localDate(tz), 1), at: null, time: null, timeZone: tz }
	let m = DATE_RE.exec(v)
	if (m) {
		const check = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
		if (check.toISOString().slice(0, 10) !== v) throw new InputError(`"${field}": ${v} is not a real calendar date.`)
		return { date: v, at: null, time: null, timeZone: tz }
	}
	m = LOCAL_DT_RE.exec(v)
	if (m) {
		const ms = zonedToUtc(tz, +m[1], +m[2], +m[3], +m[4], +m[5], +(m[6] ?? 0))
		if (Number.isNaN(ms)) throw new InputError(`"${field}": cannot parse ${v}. Accepted: ${DUE_HELP}.`)
		return { date: localDate(tz, ms), at: new Date(ms).toISOString(), time: localTime(ms, tz), timeZone: tz }
	}
	if (ZONED_DT_RE.test(v)) {
		const ms = Date.parse(v)
		if (Number.isNaN(ms)) throw new InputError(`"${field}": cannot parse ${v}. Accepted: ${DUE_HELP}.`)
		return { date: localDate(tz, ms), at: new Date(ms).toISOString(), time: localTime(ms, tz), timeZone: tz }
	}
	throw new InputError(`"${field}": cannot parse ${JSON.stringify(raw)}. Accepted: ${DUE_HELP}.`)
}

/** Sort/compare instant: exact time, or end of the local day for all-day items. */
export function dueInstant(due: Due, tz: string): number {
	if (due.at) return Date.parse(due.at)
	const [y, m, d] = due.date.split('-').map(Number)
	return zonedToUtc(due.timeZone ?? tz, y, m, d, 23, 59, 59)
}

export function isOverdue(t: Todo, tz: string, now = Date.now()): boolean {
	if (t.status !== 'open' || !t.due) return false
	if (t.due.at) return Date.parse(t.due.at) < now
	return t.due.date < localDate(tz, now)
}

// ---------- ids ----------

async function nextId(s: Store): Promise<{ id: string; seq: number }> {
	const res = await s.sql(`UPDATE todo_meta SET value = value + 1 WHERE key = 'seq' RETURNING value`, [])
	const seq = Number(rowsOf(res)[0]?.value)
	if (!Number.isFinite(seq) || seq <= 0) throw new Error('Could not allocate a todo id.')
	return { id: `T${seq}`, seq }
}

export function normalizeId(raw: string): string {
	const v = raw.trim().toUpperCase()
	if (/^\d+$/.test(v)) return `T${v}`
	if (!/^T\d+$/.test(v)) throw new InputError(`"id" must look like "T12" (or just 12). Got ${JSON.stringify(raw)}.`)
	return v
}

// ---------- CRUD ----------

export async function insertTodo(build: (id: string, now: string) => Todo): Promise<Todo> {
	const s = await db()
	const { id, seq } = await nextId(s)
	const now = new Date().toISOString()
	const todo = build(id, now)
	await s.sql(`INSERT INTO ${TABLE} (id, seq, version, json, updated_at) VALUES (?, ?, ?, ?, ?)`, [
		id,
		seq,
		todo.version,
		JSON.stringify(todo),
		now,
	])
	return todo
}

export async function getTodo(id: string): Promise<Todo | null> {
	const s = await db()
	const row = rowsOf(await s.sql(`SELECT json FROM ${TABLE} WHERE id = ? LIMIT 1`, [id]))[0]
	return row ? (JSON.parse(String(row.json)) as Todo) : null
}

export async function requireTodo(id: string): Promise<Todo> {
	const t = await getTodo(id)
	if (!t) throw new InputError(`No todo with id ${id}. Use the ./list export to find ids.`)
	return t
}

export async function allTodos(): Promise<Array<Todo>> {
	const s = await db()
	const out: Array<Todo> = []
	for (const row of rowsOf(await s.sql(`SELECT json FROM ${TABLE} ORDER BY seq`, []))) {
		try {
			out.push(JSON.parse(String(row.json)) as Todo)
		} catch {
			// skip corrupt rows
		}
	}
	return out
}

const HISTORY_CAP = 50

/**
 * Optimistic-concurrency update: re-read, apply mutate, write only if version unchanged.
 * Retries a few times so two agents editing the same item never silently overwrite each other.
 */
export async function mutateTodo(
	id: string,
	by: string,
	action: string,
	mutate: (t: Todo) => Record<string, unknown> | void,
): Promise<Todo> {
	const s = await db()
	for (let attempt = 0; attempt < 5; attempt++) {
		const current = await requireTodo(id)
		const next: Todo = structuredClone(current)
		const changes = mutate(next) ?? undefined
		const now = new Date().toISOString()
		next.version = current.version + 1
		next.updatedAt = now
		next.updatedBy = by
		next.history = [...(current.history ?? []), { at: now, by, action, ...(changes ? { changes } : {}) }].slice(-HISTORY_CAP)
		const res = await s.sql(
			`UPDATE ${TABLE} SET version = ?, json = ?, updated_at = ? WHERE id = ? AND version = ? RETURNING id`,
			[next.version, JSON.stringify(next), now, id, current.version],
		)
		if (rowsOf(res).length > 0) return next
	}
	throw new Error(`Todo ${id} is being edited concurrently; retry the call.`)
}

export async function purgeTodo(id: string): Promise<boolean> {
	const s = await db()
	return rowsOf(await s.sql(`DELETE FROM ${TABLE} WHERE id = ? RETURNING id`, [id])).length > 0
}

// ---------- presentation ----------

const PRIORITY_RANK: Record<Priority, number> = { high: 0, normal: 1, low: 2 }

export function compareTodos(a: Todo, b: Todo, tz: string): number {
	if (a.due && !b.due) return -1
	if (!a.due && b.due) return 1
	if (a.due && b.due) {
		const d = dueInstant(a.due, tz) - dueInstant(b.due, tz)
		if (d !== 0) return d
	}
	const p = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]
	if (p !== 0) return p
	return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
}

export function formatDue(due: Due | null): string {
	if (!due) return 'no due date'
	return due.time ? `${due.date} ${due.time}` : due.date
}

/** One-line human summary, e.g. `T12 [high] Call the bank — due 2026-10-14 (project: Website) #finance`. */
export function line(t: Todo, tz: string, now = Date.now()): string {
	const bits = [`${t.id}`]
	if (t.priority !== 'normal') bits.push(`[${t.priority}]`)
	if (t.kind === 'task') bits.push('(task)')
	bits.push(t.title)
	let s = bits.join(' ')
	if (t.status === 'done') s += ' ✓'
	if (t.due) s += ` — due ${formatDue(t.due)}${isOverdue(t, tz, now) ? ' (OVERDUE)' : ''}`
	const ctx = [t.project ? `project: ${t.project}` : '', t.client ? `client: ${t.client}` : ''].filter(Boolean)
	if (ctx.length) s += ` (${ctx.join(', ')})`
	if (t.tags.length) s += ' ' + t.tags.map((x) => `#${x}`).join(' ')
	return s
}

/** Compact record for list results (no history). */
export function summary(t: Todo, tz: string, now = Date.now()) {
	const { history: _h, version: _v, ...rest } = t
	return { ...rest, overdue: isOverdue(t, tz, now), line: line(t, tz, now) }
}

export function normalizeTags(tags: Array<string>): Array<string> {
	return [...new Set(tags.map((t) => t.replace(/^#/, '').trim().toLowerCase()).filter(Boolean))]
}