"""Durable, account-scoped ordered Today plans.""" import json import sqlite3 import time from datetime import date from pathlib import Path class TodayPlanFull(ValueError): """Raised when an add would exceed the bounded Today plan.""" class TodayStore: def __init__( self, path: str | Path, *, limit: int = 5, timeout: float = 1.0, operation_limit: int = 4096, operation_retention_seconds: float = 30 * 24 * 60 * 60, recap_limit: int = 100, clock=time.time, ): self.path = Path(path) self.limit = limit self.timeout = timeout self.operation_limit = operation_limit self.operation_retention_seconds = operation_retention_seconds self.recap_limit = recap_limit self.clock = clock def _connect(self) -> sqlite3.Connection: self.path.parent.mkdir(parents=True, exist_ok=True) connection = sqlite3.connect(self.path, timeout=self.timeout) connection.execute("PRAGMA journal_mode=WAL") connection.execute( """ CREATE TABLE IF NOT EXISTS today_plans ( login TEXT PRIMARY KEY, revision INTEGER NOT NULL, ids TEXT NOT NULL, capacity_minutes INTEGER, estimates TEXT NOT NULL DEFAULT '{}', plan_date TEXT, timezone TEXT ) """ ) plan_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_plans)")} if "capacity_minutes" not in plan_columns: connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER") if "estimates" not in plan_columns: connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'") if "plan_date" not in plan_columns: connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT") if "timezone" not in plan_columns: connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT") connection.execute( """ CREATE TABLE IF NOT EXISTS today_operations ( login TEXT NOT NULL, operation_id TEXT NOT NULL, created_at REAL NOT NULL, PRIMARY KEY (login, operation_id) ) """ ) columns = {row[1] for row in connection.execute("PRAGMA table_info(today_operations)")} if "created_at" not in columns: connection.execute("ALTER TABLE today_operations ADD COLUMN created_at REAL") connection.execute( "UPDATE today_operations SET created_at = ? WHERE created_at IS NULL", (self.clock(),), ) connection.execute( """ CREATE TABLE IF NOT EXISTS today_recaps ( login TEXT NOT NULL, session_id TEXT NOT NULL, created_at REAL NOT NULL, items TEXT NOT NULL, PRIMARY KEY (login, session_id) ) """ ) connection.execute( "CREATE INDEX IF NOT EXISTS today_recaps_recent " "ON today_recaps(login, created_at DESC)" ) connection.execute( """ CREATE TABLE IF NOT EXISTS today_time_logs ( login TEXT NOT NULL, session_id TEXT NOT NULL, identity TEXT NOT NULL, actual_minutes INTEGER NOT NULL, status TEXT NOT NULL, PRIMARY KEY (login, session_id, identity) ) """ ) connection.commit() return connection def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None: now = self.clock() connection.execute( "INSERT INTO today_operations(login, operation_id, created_at) VALUES (?, ?, ?)", (login, operation_id, now), ) connection.execute( "DELETE FROM today_operations WHERE login = ? AND created_at < ?", (login, now - self.operation_retention_seconds), ) connection.execute( "DELETE FROM today_operations WHERE login = ? AND rowid NOT IN " "(SELECT rowid FROM today_operations WHERE login = ? " "ORDER BY created_at DESC, rowid DESC LIMIT ?)", (login, login, self.operation_limit), ) @staticmethod def _normalize_login(login: str) -> str: normalized = login.strip().lower() if not normalized: raise ValueError("login is required") return normalized @staticmethod def _snapshot(row) -> dict: if row is None: return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}} ids = json.loads(row[1]) estimates = json.loads(row[3] or "{}") snapshot = { "revision": int(row[0]), "ids": ids, "capacity_minutes": row[2], "estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids}, } if len(row) > 4 and row[4]: snapshot["plan_date"] = row[4] snapshot["timezone"] = row[5] return snapshot def get(self, login: str) -> dict: with self._connect() as connection: row = connection.execute( "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " "FROM today_plans WHERE login = ?", (self._normalize_login(login),), ).fetchone() return self._snapshot(row) @staticmethod def _normalize_recap_items(items: list[dict]) -> list[dict]: if not isinstance(items, list) or not items: raise ValueError("recap requires at least one item") if len(items) > 20: raise ValueError("recap is limited to 20 items") normalized = [] seen = set() for item in items: identity = item.get("identity", "") if isinstance(item, dict) else "" if not isinstance(identity, str) or not identity.strip() or len(identity) > 500: raise ValueError("recap item identity is required and bounded") identity = identity.strip() if identity in seen: raise ValueError("recap item identities must be unique") actual = item.get("actual_minutes") estimate = item.get("estimate_minutes") if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440: raise ValueError("actual minutes must be between 0 and 1440") if estimate is not None and ( not isinstance(estimate, int) or isinstance(estimate, bool) or estimate < 5 or estimate > 1440 ): raise ValueError("estimate minutes must be between 5 and 1440") seen.add(identity) normalized.append({ "identity": identity, "estimate_minutes": estimate, "actual_minutes": actual, }) return normalized @staticmethod def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict: items = json.loads(serialized) estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None) actual = sum(item["actual_minutes"] for item in items) return { "session_id": session_id, "created_at": created_at, "items": items, "estimated_minutes": estimated, "actual_minutes": actual, "variance_minutes": actual - estimated, } def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict: login = self._normalize_login(login) if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100: raise ValueError("session_id is required and bounded") session_id = session_id.strip() normalized = self._normalize_recap_items(items) serialized = json.dumps(normalized, separators=(",", ":")) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") existing = connection.execute( "SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?", (login, session_id), ).fetchone() if existing is not None: return self._recap_snapshot(session_id, existing[0], existing[1]) created_at = self.clock() connection.execute( "INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)", (login, session_id, created_at, serialized), ) connection.execute( "DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN " "(SELECT rowid FROM today_recaps WHERE login = ? " "ORDER BY created_at DESC, rowid DESC LIMIT ?)", (login, login, self.recap_limit), ) return self._recap_snapshot(session_id, created_at, serialized) def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]: bounded_limit = max(1, min(int(limit), self.recap_limit, 100)) with self._connect() as connection: rows = connection.execute( "SELECT session_id, created_at, items FROM today_recaps WHERE login = ? " "ORDER BY created_at DESC, rowid DESC LIMIT ?", (self._normalize_login(login), bounded_limit), ).fetchall() return [self._recap_snapshot(*row) for row in rows] def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str: """Claim one recap item for upstream logging, returning its current state.""" login = self._normalize_login(login) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT actual_minutes, status FROM today_time_logs " "WHERE login = ? AND session_id = ? AND identity = ?", (login, session_id, identity), ).fetchone() if row is None: connection.execute( "INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) " "VALUES (?, ?, ?, ?, 'pending')", (login, session_id, identity, actual_minutes), ) return "claimed" if row[0] != actual_minutes: raise ValueError("logged recap time cannot be changed") if row[1] == "failed": connection.execute( "UPDATE today_time_logs SET status = 'pending' " "WHERE login = ? AND session_id = ? AND identity = ?", (login, session_id, identity), ) return "claimed" return row[1] def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None: with self._connect() as connection: connection.execute( "UPDATE today_time_logs SET status = ? " "WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'", ("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity), ) def apply( self, login: str, operation_id: str, action: str, item_id: str, *, direction: str | None = None, ) -> dict: login = self._normalize_login(login) if not operation_id or not item_id: raise ValueError("operation_id and item_id are required") if action not in {"add", "remove", "move"}: raise ValueError("unsupported Today action") if action == "move" and direction not in {"up", "down"}: raise ValueError("move direction must be up or down") with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " "FROM today_plans WHERE login = ?", (login,) ).fetchone() snapshot = self._snapshot(row) duplicate = connection.execute( "SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?", (login, operation_id), ).fetchone() if duplicate: return snapshot ids = list(snapshot["ids"]) estimates = dict(snapshot["estimates"]) changed = False if action == "add": if item_id not in ids: if len(ids) >= self.limit: raise TodayPlanFull("Today is limited to five items") ids.append(item_id) changed = True elif action == "remove": if item_id in ids: ids.remove(item_id) estimates.pop(item_id, None) changed = True else: try: index = ids.index(item_id) except ValueError: index = -1 target = index - 1 if direction == "up" else index + 1 if index >= 0 and 0 <= target < len(ids): ids[index], ids[target] = ids[target], ids[index] changed = True revision = snapshot["revision"] + (1 if changed else 0) serialized_ids = json.dumps(ids, separators=(",", ":")) serialized_estimates = json.dumps(estimates, separators=(",", ":")) if row is None: connection.execute( "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates, snapshot.get("plan_date"), snapshot.get("timezone")), ) elif changed: connection.execute( "UPDATE today_plans SET revision = ?, ids = ?, estimates = ? WHERE login = ?", (revision, serialized_ids, serialized_estimates, login), ) self._record_operation(connection, login, operation_id) result = { "revision": revision, "ids": ids, "capacity_minutes": snapshot["capacity_minutes"], "estimates": estimates, } if snapshot.get("plan_date"): result["plan_date"] = snapshot["plan_date"] result["timezone"] = snapshot["timezone"] return result def apply_batch(self, login: str, operations: list[dict]) -> dict: """Apply an ordered batch with one lock and receipt per operation.""" login = self._normalize_login(login) with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " "FROM today_plans WHERE login = ?", (login,) ).fetchone() snapshot = self._snapshot(row) ids = list(snapshot["ids"]) capacity_minutes = snapshot["capacity_minutes"] estimates = dict(snapshot["estimates"]) plan_date = snapshot.get("plan_date") timezone = snapshot.get("timezone") revision = snapshot["revision"] accepted: list[str] = [] duplicates: list[str] = [] rejected: list[dict[str, str]] = [] for operation in operations: operation_id = operation.get("operation_id", "") action = operation.get("action", "") item_id = operation.get("item_id", "") direction = operation.get("direction") if not operation_id or not item_id: raise ValueError("operation_id and item_id are required") if action not in {"add", "remove", "move", "configure", "rollover"}: raise ValueError("unsupported Today action") if action == "move" and direction not in {"up", "down"}: raise ValueError("move direction must be up or down") duplicate = connection.execute( "SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?", (login, operation_id), ).fetchone() if duplicate: duplicates.append(operation_id) continue base_revision = operation.get("base_revision") if base_revision is None: base_revision = snapshot["revision"] if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0: raise ValueError("base_revision must be a non-negative integer") if base_revision < snapshot["revision"]: self._record_operation(connection, login, operation_id) rejected.append({"operation_id": operation_id, "reason": "stale_intent"}) continue changed = False if action == "add": if item_id not in ids: if len(ids) >= self.limit: rejected.append({"operation_id": operation_id, "reason": "today_full"}) continue ids.append(item_id) changed = True elif action == "remove": if item_id in ids: ids.remove(item_id) estimates.pop(item_id, None) changed = True elif action == "move": try: index = ids.index(item_id) except ValueError: index = -1 target = index - 1 if direction == "up" else index + 1 if index >= 0 and 0 <= target < len(ids): ids[index], ids[target] = ids[target], ids[index] changed = True elif action == "configure": proposed_capacity = operation.get("capacity_minutes") if proposed_capacity is not None and ( not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool) or proposed_capacity < 15 or proposed_capacity > 1440 ): raise ValueError("capacity_minutes must be between 15 and 1440") proposed_estimates = operation.get("estimates", {}) if not isinstance(proposed_estimates, dict): raise ValueError("estimates must be an object") normalized_estimates = {} for estimate_id, minutes in proposed_estimates.items(): if estimate_id not in ids: continue if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440: raise ValueError("estimate minutes must be between 5 and 1440") normalized_estimates[estimate_id] = minutes changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates capacity_minutes = proposed_capacity estimates = normalized_estimates else: proposed_date = operation.get("plan_date") proposed_timezone = operation.get("timezone") try: if date.fromisoformat(proposed_date or "").isoformat() != proposed_date: raise ValueError except (TypeError, ValueError): raise ValueError("plan_date must be an ISO calendar date") from None if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100: raise ValueError("timezone is required and bounded") proposed_ids = operation.get("ids") if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any( not isinstance(candidate, str) or not candidate or len(candidate) > 500 for candidate in proposed_ids ): raise ValueError("rollover IDs are invalid or exceed the Today limit") if len(set(proposed_ids)) != len(proposed_ids): raise ValueError("rollover IDs must be unique") proposed_capacity = operation.get("capacity_minutes") if proposed_capacity is not None and ( not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool) or proposed_capacity < 15 or proposed_capacity > 1440 ): raise ValueError("capacity_minutes must be between 15 and 1440") proposed_estimates = operation.get("estimates", {}) if not isinstance(proposed_estimates, dict): raise ValueError("estimates must be an object") normalized_estimates = {} for estimate_id, minutes in proposed_estimates.items(): if estimate_id not in proposed_ids: continue if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440: raise ValueError("estimate minutes must be between 5 and 1440") normalized_estimates[estimate_id] = minutes changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or estimates != normalized_estimates or plan_date != proposed_date or timezone != proposed_timezone.strip()) ids = list(proposed_ids) capacity_minutes = proposed_capacity estimates = normalized_estimates plan_date = proposed_date timezone = proposed_timezone.strip() revision += 1 if changed else 0 self._record_operation(connection, login, operation_id) accepted.append(operation_id) serialized = json.dumps(ids, separators=(",", ":")) serialized_estimates = json.dumps(estimates, separators=(",", ":")) if row is None: connection.execute( "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone), ) elif accepted: connection.execute( "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, " "plan_date = ?, timezone = ? WHERE login = ?", (revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login), ) result = { "revision": revision, "ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates, "accepted_operation_ids": accepted, "duplicate_operation_ids": duplicates, "rejected_operations": rejected, } if plan_date: result["plan_date"] = plan_date result["timezone"] = timezone return result