← Public packages
@charlesmatte/todos
Shared todo, task and reminder store that any of your Kody-connected agents can use.
src/lib/store.ts
347 lines · 12.0 KB · TypeScriptimport { 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))]
}