diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 14fac6f..e86a1e9 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -139,6 +139,23 @@
storage: localStorage,
getLogin: () => planningOwnerLogin,
});
+ const todaySync = createTodaySync({
+ storage: localStorage,
+ getLogin: () => planningOwnerLogin,
+ fetchJson: fetchReviewJson,
+ onRemoteIds: ids => {
+ if (!planningOwnerLogin || !todayWork.replace(ids)) return;
+ refreshMyWorkView();
+ warmTodayOffline();
+ },
+ onStatus: state => {
+ const status = qs('#today-sync-status');
+ status.textContent = state === 'saved' ? 'Today saved to account.' :
+ (state === 'pending' ? 'Today saved on this device · sync pending.' :
+ (state === 'full' ? 'Another device filled Today · showing its saved plan.' :
+ 'Today sync unavailable · changes stay on this device.'));
+ },
+ });
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
@@ -424,7 +441,11 @@
addToday: item => {
const result = todayWork.add(item);
refreshMyWorkView();
- if (result === 'added') warmTodayOffline();
+ if (result === 'added') {
+ todaySync.enqueue('add', todayWork.identity(item));
+ todaySync.flush();
+ warmTodayOffline();
+ }
return result;
},
onClaimed: item => {
@@ -1077,19 +1098,32 @@
(result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView();
- if (result === 'added') warmTodayOffline();
+ if (result === 'added') {
+ const item = lastMyWork[Number(button.dataset.workIndex)];
+ todaySync.enqueue('add', todayWork.identity(item));
+ todaySync.flush();
+ warmTodayOffline();
+ }
});
});
document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => {
- todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
+ const item = lastMyWork[Number(button.dataset.workIndex)];
+ if (todayWork.remove(item)) {
+ todaySync.enqueue('remove', todayWork.identity(item));
+ todaySync.flush();
+ }
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView();
});
});
document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => {
- todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
+ const item = lastMyWork[Number(button.dataset.workIndex)];
+ if (todayWork.move(item, button.dataset.todayMove)) {
+ todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
+ todaySync.flush();
+ }
refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
});
@@ -1971,6 +2005,10 @@
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability();
+ if (planningOwnerLogin) {
+ todaySync.migrate(todayWork.read());
+ todaySync.flush();
+ }
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
diff --git a/frontend/index.html b/frontend/index.html
index 01b7429..b9d43cc 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -99,6 +99,7 @@
+ Today is saved on this device.
@@ -538,6 +539,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 3117078..d95dc16 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
-const CACHE = 'stackchain-dashboard-shell-v44';
+const CACHE = 'stackchain-dashboard-shell-v45';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -25,6 +25,7 @@ const SHELL = [
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/today-work.js',
+ BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js',
diff --git a/frontend/today-sync.js b/frontend/today-sync.js
new file mode 100644
index 0000000..71a5093
--- /dev/null
+++ b/frontend/today-sync.js
@@ -0,0 +1,106 @@
+function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId }) {
+ const prefix = 'stackchain.today-sync.v1.';
+ const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
+ let flushing = null;
+
+ function key() {
+ const login = String(getLogin?.() || '').trim().toLowerCase();
+ return login ? prefix + encodeURIComponent(login) : '';
+ }
+
+ function pending() {
+ const storageKey = key();
+ if (!storageKey || !storage) return [];
+ try {
+ const value = JSON.parse(storage.getItem(storageKey) || '[]');
+ return Array.isArray(value) ? value.filter(operation =>
+ operation && typeof operation.operation_id === 'string' &&
+ ['add', 'remove', 'move'].includes(operation.action) &&
+ typeof operation.item_id === 'string'
+ ) : [];
+ } catch (_error) {
+ return [];
+ }
+ }
+
+ function save(operations) {
+ const storageKey = key();
+ if (!storageKey || !storage) return false;
+ try {
+ if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
+ else storage.removeItem(storageKey);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ function operationId() {
+ if (createOperationId) return createOperationId();
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
+ return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
+ }
+
+ function enqueue(action, itemId, direction = null) {
+ const operations = pending();
+ operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
+ const saved = save(operations);
+ onStatus?.(saved ? 'pending' : 'error');
+ return saved;
+ }
+
+ function migrate(ids) {
+ const storageKey = key();
+ if (!storageKey || !storage) return false;
+ const marker = migrationPrefix + storageKey.slice(prefix.length);
+ try {
+ if (storage.getItem(marker)) return false;
+ for (const id of ids || []) enqueue('add', id);
+ storage.setItem(marker, '1');
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ async function run() {
+ if (!key()) return false;
+ try {
+ let plan = await fetchJson('api/v1/today');
+ const operations = pending();
+ for (const operation of operations) {
+ try {
+ plan = await fetchJson('api/v1/today', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(operation),
+ });
+ } catch (error) {
+ if (error?.status !== 409) throw error;
+ const rejected = pending();
+ save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
+ onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
+ onStatus?.('full');
+ return false;
+ }
+ const remaining = pending();
+ if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1));
+ }
+ onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
+ onStatus?.(pending().length ? 'pending' : 'saved');
+ return true;
+ } catch (_error) {
+ onStatus?.(pending().length ? 'pending' : 'error');
+ return false;
+ }
+ }
+
+ function flush() {
+ if (!flushing) flushing = run().finally(() => { flushing = null; });
+ return flushing;
+ }
+
+ return { enqueue, migrate, flush, pending };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
diff --git a/frontend/today-work.js b/frontend/today-work.js
index 60cab97..2c1ec0f 100644
--- a/frontend/today-work.js
+++ b/frontend/today-work.js
@@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
+ function replace(ids) {
+ const unique = [];
+ for (const id of ids || []) {
+ if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
+ unique.push(id);
+ }
+ }
+ return write(unique);
+ }
+
function remove(item) {
const id = identity(item);
const ids = read();
@@ -85,7 +95,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
- return { identity, add, remove, move, reconcile, contains, position, limit };
+ return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
diff --git a/src/main.py b/src/main.py
index 8a462fa..297e491 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2,6 +2,7 @@ import asyncio
import hmac
import math
import os
+import sqlite3
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
@@ -34,6 +35,7 @@ from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, c
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
+from src.today_store import TodayPlanFull, TodayStore
from src.views import router as frontend_router
@asynccontextmanager
@@ -199,6 +201,21 @@ class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
+class TodayOperation(BaseModel):
+ operation_id: str = Field(min_length=1, max_length=100)
+ action: Literal["add", "remove", "move"]
+ item_id: str = Field(min_length=1, max_length=500)
+ direction: Literal["up", "down"] | None = None
+
+ @model_validator(mode="after")
+ def require_move_direction(self):
+ if self.action == "move" and self.direction is None:
+ raise ValueError("move requires a direction")
+ if self.action != "move" and self.direction is not None:
+ raise ValueError("direction is only valid for move")
+ return self
+
+
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@@ -616,7 +633,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next):
response = await call_next(request)
path = dashboard_auth.application_path(request)
- if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route"} or path.startswith("/api/v1/work/") or (
+ if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -838,6 +855,58 @@ async def session_status(request: Request):
}
+def _today_store() -> TodayStore:
+ return TodayStore(
+ os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
+ )
+
+
+async def _confirmed_login() -> str:
+ try:
+ user = await asyncio.wait_for(
+ current_user(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
+ )
+ except Exception as exc:
+ raise HTTPException(
+ status_code=503, detail="Operator identity is unavailable"
+ ) from exc
+ login = user.get("login", "") if isinstance(user, dict) else ""
+ if not isinstance(login, str) or not login.strip():
+ raise HTTPException(status_code=503, detail="Operator identity is unavailable")
+ return login.strip().lower()
+
+
+@app.get("/api/v1/today")
+async def get_today_plan():
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(_today_store().get, login)
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503, detail="Today synchronization is unavailable"
+ )
+
+
+@app.patch("/api/v1/today")
+async def update_today_plan(payload: TodayOperation):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _today_store().apply,
+ login,
+ payload.operation_id,
+ payload.action,
+ payload.item_id,
+ direction=payload.direction,
+ )
+ except TodayPlanFull:
+ raise HTTPException(status_code=409, detail="Today is limited to 5 items")
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503, detail="Today synchronization is unavailable"
+ )
+
+
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
diff --git a/src/today_store.py b/src/today_store.py
new file mode 100644
index 0000000..96d3ab0
--- /dev/null
+++ b/src/today_store.py
@@ -0,0 +1,130 @@
+"""Durable, account-scoped ordered Today plans."""
+
+import json
+import sqlite3
+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):
+ self.path = Path(path)
+ self.limit = limit
+ self.timeout = timeout
+
+ 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
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS today_operations (
+ login TEXT NOT NULL,
+ operation_id TEXT NOT NULL,
+ PRIMARY KEY (login, operation_id)
+ )
+ """
+ )
+ return connection
+
+ @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": []}
+ return {"revision": int(row[0]), "ids": json.loads(row[1])}
+
+ def get(self, login: str) -> dict:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT revision, ids FROM today_plans WHERE login = ?",
+ (self._normalize_login(login),),
+ ).fetchone()
+ return self._snapshot(row)
+
+ 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 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"])
+ 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)
+ 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)
+ if row is None:
+ connection.execute(
+ "INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
+ (login, revision, json.dumps(ids, separators=(",", ":"))),
+ )
+ elif changed:
+ connection.execute(
+ "UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
+ (revision, json.dumps(ids, separators=(",", ":")), login),
+ )
+ connection.execute(
+ "INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
+ (login, operation_id),
+ )
+ return {"revision": revision, "ids": ids}
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index e82bd7b..035be56 100644
--- a/tests/test_markdown_renderer.py
+++ b/tests/test_markdown_renderer.py
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
- assert "stackchain-dashboard-shell-v44" in worker
+ assert "stackchain-dashboard-shell-v45" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 3be6439..97dce9a 100644
--- a/tests/test_mobile_composer_integration.py
+++ b/tests/test_mobile_composer_integration.py
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
- assert "stackchain-dashboard-shell-v44" in worker
+ assert "stackchain-dashboard-shell-v45" in worker
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 9967312..7e3fca6 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v44" in source
+ assert "stackchain-dashboard-shell-v45" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -117,14 +117,14 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v44" in source
+ assert "stackchain-dashboard-shell-v45" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v44" in source
+ assert "stackchain-dashboard-shell-v45" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -319,6 +319,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/today-work.js",
+ "/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js",
diff --git a/tests/test_today_store.py b/tests/test_today_store.py
new file mode 100644
index 0000000..d4b5636
--- /dev/null
+++ b/tests/test_today_store.py
@@ -0,0 +1,105 @@
+import pytest
+import httpx
+
+from src import main
+from src.today_store import TodayPlanFull, TodayStore
+
+
+def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
+ path = tmp_path / "today.sqlite3"
+ store = TodayStore(path, limit=3)
+
+ assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
+ "revision": 1,
+ "ids": ["issue:stackchain/dashboard:1:"],
+ }
+ store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
+ store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
+ moved = store.apply(
+ "timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
+ )
+ duplicate = store.apply(
+ "timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
+ )
+
+ assert moved == duplicate == {
+ "revision": 4,
+ "ids": [
+ "issue:stackchain/dashboard:1:",
+ "issue:stackchain/dashboard:3:",
+ "issue:stackchain/dashboard:2:",
+ ],
+ }
+ assert TodayStore(path, limit=3).get("timmy") == moved
+ assert store.get("alexander") == {"revision": 0, "ids": []}
+
+
+def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
+ store = TodayStore(tmp_path / "today.sqlite3", limit=2)
+ store.apply("timmy", "one", "add", "issue:r:1:")
+ store.apply("timmy", "two", "add", "issue:r:2:")
+
+ with pytest.raises(TodayPlanFull):
+ store.apply("timmy", "three", "add", "issue:r:3:")
+
+ assert store.get("timmy") == {
+ "revision": 2,
+ "ids": ["issue:r:1:", "issue:r:2:"],
+ }
+ store.apply("timmy", "remove", "remove", "issue:r:1:")
+ assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
+ "issue:r:2:",
+ "issue:r:3:",
+ ]
+
+
+@pytest.mark.anyio
+async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
+ monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
+ monkeypatch.setenv(
+ "STACKCHAIN_DASHBOARD_SESSION_SECRET",
+ "a-separate-session-signing-secret-with-enough-entropy",
+ )
+ monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
+ monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
+
+ async def user():
+ return {"id": 1, "login": "Timmy"}
+
+ monkeypatch.setattr(main, "current_user", user)
+ transport = httpx.ASGITransport(app=main.app)
+ async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
+ await client.post(
+ "/api/v1/session", json={"access_token": "correct horse battery staple"}
+ )
+ forbidden = await client.patch(
+ "/api/v1/today",
+ json={
+ "operation_id": "mobile-1",
+ "action": "add",
+ "item_id": "issue:stackchain/dashboard:357:",
+ },
+ )
+ changed = await client.patch(
+ "/api/v1/today",
+ json={
+ "operation_id": "mobile-1",
+ "action": "add",
+ "item_id": "issue:stackchain/dashboard:357:",
+ },
+ headers={
+ "Origin": "https://test",
+ "X-CSRF-Token": client.cookies["stackchain_csrf"],
+ },
+ )
+ fetched = await client.get("/api/v1/today")
+
+ assert forbidden.status_code == 403
+ assert changed.status_code == 200
+ assert changed.json() == fetched.json() == {
+ "revision": 1,
+ "ids": ["issue:stackchain/dashboard:357:"],
+ }
+ assert fetched.headers["cache-control"] == "no-store"
diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py
new file mode 100644
index 0000000..cd4a5ac
--- /dev/null
+++ b/tests/test_today_sync.py
@@ -0,0 +1,142 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
+
+
+def test_local_operations_replay_once_then_adopt_server_order():
+ script = f"""
+const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+const values = new Map();
+const storage = {{
+ getItem: key => values.get(key) || null,
+ setItem: (key, value) => values.set(key, value),
+ removeItem: key => values.delete(key),
+}};
+const requests = [];
+let remote = {{revision: 1, ids:['issue:r:9:']}};
+const sync = createTodaySync({{
+ storage,
+ getLogin: () => 'timmy',
+ createOperationId: () => 'fixed-op',
+ fetchJson: async (url, options={{}}) => {{
+ requests.push({{url, body: options.body && JSON.parse(options.body)}});
+ if (!options.method) return remote;
+ remote = {{revision: remote.revision + 1, ids:['issue:r:9:', options.body && JSON.parse(options.body).item_id]}};
+ return remote;
+ }},
+ onRemoteIds: ids => {{ globalThis.adopted = ids; }},
+ onStatus: status => {{ globalThis.status = status; }},
+}});
+sync.enqueue('add', 'issue:r:2:');
+(async () => {{
+ await sync.flush();
+ await sync.flush();
+ process.stdout.write(JSON.stringify({{
+ requests, adopted: globalThis.adopted, status: globalThis.status,
+ pending: sync.pending(),
+ }}));
+}})();
+"""
+ result = json.loads(
+ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
+ )
+
+ assert [request["url"] for request in result["requests"]] == [
+ "api/v1/today",
+ "api/v1/today",
+ "api/v1/today",
+ ]
+ assert result["requests"][1]["body"] == {
+ "operation_id": "fixed-op",
+ "action": "add",
+ "item_id": "issue:r:2:",
+ "direction": None,
+ }
+ assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
+ assert result["status"] == "saved"
+ assert result["pending"] == []
+
+
+def test_failed_delivery_stays_pending_for_offline_replay():
+ script = f"""
+const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+const values = new Map();
+const sync = createTodaySync({{
+ storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin: () => 'timmy', createOperationId: () => 'offline-op',
+ fetchJson: async () => {{ throw new Error('offline'); }},
+ onRemoteIds: () => {{}}, onStatus: value => {{ globalThis.status = value; }},
+}});
+sync.enqueue('move', 'issue:r:2:', 'up');
+(async () => {{ await sync.flush(); process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}})); }})();
+"""
+ result = json.loads(
+ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
+ )
+ assert result == {
+ "pending": [{
+ "operation_id": "offline-op",
+ "action": "move",
+ "item_id": "issue:r:2:",
+ "direction": "up",
+ }],
+ "status": "pending",
+ }
+
+
+def test_existing_device_queue_is_migrated_only_once():
+ script = f"""
+const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+const values = new Map();
+let sequence = 0;
+const sync = createTodaySync({{
+ storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin: () => 'timmy', createOperationId: () => 'migration-' + (++sequence),
+ fetchJson: async () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
+}});
+const first = sync.migrate(['issue:r:1:', 'issue:r:2:']);
+const second = sync.migrate(['issue:r:1:', 'issue:r:2:']);
+process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
+"""
+ result = json.loads(
+ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
+ )
+ assert result == {
+ "first": True,
+ "second": False,
+ "pending": [
+ {"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
+ {"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
+ ],
+ }
+
+
+def test_server_limit_conflict_drops_rejected_add_and_adopts_server_truth():
+ script = f"""
+const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+const values = new Map();
+let patch = false;
+const sync = createTodaySync({{
+ storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin: () => 'timmy', createOperationId: () => 'sixth',
+ fetchJson: async (_url, options={{}}) => {{
+ if (options.method) {{ patch = true; const error = new Error('full'); error.status = 409; throw error; }}
+ return {{revision:5, ids:['1','2','3','4','5']}};
+ }},
+ onRemoteIds:ids=>{{globalThis.ids=ids}}, onStatus:value=>{{globalThis.status=value}},
+}});
+sync.enqueue('add', '6');
+(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{patch,pending:sync.pending(),ids:globalThis.ids,status:globalThis.status}}));}})();
+"""
+ result = json.loads(
+ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
+ )
+ assert result == {
+ "patch": True,
+ "pending": [],
+ "ids": ["1", "2", "3", "4", "5"],
+ "status": "full",
+ }
diff --git a/tests/test_today_work.py b/tests/test_today_work.py
index 3433b09..082ffbe 100644
--- a/tests/test_today_work.py
+++ b/tests/test_today_work.py
@@ -78,11 +78,28 @@ process.stdout.write(JSON.stringify([
]
+def test_today_queue_adopts_bounded_server_order():
+ script = f"""
+const createTodayWork = require({json.dumps(str(TODAY_WORK))});
+const values = new Map();
+const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
+const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
+queue.add({{kind:'issue', repository:'r', number:1}});
+const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
+process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
+"""
+ assert json.loads(run_node(script)) == {
+ "adopted": True,
+ "ids": ["issue:r:3:", "issue:r:2:"],
+ }
+
+
@pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
html = await dashboard()
assert '' in html
+ assert '' in html
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
@@ -94,6 +111,12 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' in html
+ assert 'id="today-sync-status"' in html
+ assert "todaySync.enqueue('add'" in html
+ assert "todaySync.enqueue('remove'" in html
+ assert "todaySync.enqueue('move'" in html
+ assert "todaySync.flush();" in html
+ assert "Another device filled Today · showing its saved plan." in html
@pytest.mark.anyio
@@ -101,7 +124,7 @@ async def test_retained_authenticated_context_keeps_local_planning_separate_from
html = await dashboard()
assert "let planningOwnerLogin = '';" in html
- assert html.count("getLogin: () => planningOwnerLogin") == 2
+ assert html.count("getLogin: () => planningOwnerLogin") == 3
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html