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 } export type Todo = { id: string kind: Kind title: string notes: string | null status: 'open' | 'done' deleted: boolean priority: Priority tags: Array 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 } type Store = ReturnType const TABLE = 'todos' let ready: Promise | 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:@/todos/'`. " + `Underlying error: ${error instanceof Error ? error.message : String(error)}`, ) } } function rowsOf(result: unknown): Array> { if (result && typeof result === 'object' && Array.isArray((result as { rows?: unknown }).rows)) { return (result as { rows: Array> }).rows } return [] } async function db(): Promise { 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 { 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 { 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 = {} 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 { 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 { 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 { 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> { const s = await db() const out: Array = [] 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 | void, ): Promise { 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 { const s = await db() return rowsOf(await s.sql(`DELETE FROM ${TABLE} WHERE id = ? RETURNING id`, [id])).length > 0 } // ---------- presentation ---------- const PRIORITY_RANK: Record = { 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): Array { return [...new Set(tags.map((t) => t.replace(/^#/, '').trim().toLowerCase()).filter(Boolean))] }