diff --git a/README.md b/README.md
index f46b198..6430e91 100644
--- a/README.md
+++ b/README.md
@@ -211,13 +211,15 @@ filter, preserves the selected release lane, shows the current draft count, resp
the device safe area, and moves out of the way while a full-screen task is open.
Desktop layout is unchanged.
-My Work also has a local **Later** queue. **Later today** defers an item for four
+My Work also has an account-synced **Later** queue. **Later today** defers an item for four
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
Deferred items leave normal and Attention queues without marking notifications read
or changing any Gitea issue or pull request. They automatically return to their
existing priority position at the wake time, and **Bring back now** restores them
-early. Later state is stored only in this browser, scoped to the confirmed Gitea
-login, and removed when fully loaded work confirms that an item no longer exists.
+early. Later wake times are scoped to the confirmed Gitea login and synchronize
+across signed-in tabs and devices. Offline changes apply immediately, survive reload,
+and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
+store at `.stackchain-state/later.sqlite3`.
After one successful online load, the installed dashboard precaches a versioned,
subpath-scoped application shell. During a network outage or a dashboard HTTP
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 0312572..2d3cbbd 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -160,11 +160,33 @@
const laterWork = createLaterWork({
storage: localStorage,
getLogin: () => planningOwnerLogin,
+ onChange: (action, itemId, wakeAt) => {
+ if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
+ },
+ onExpire: ids => {
+ const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
+ if (queued) laterSync.flush();
+ },
onWake: () => {
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
refreshMyWorkView();
},
});
+ const laterSync = createLaterSync({
+ storage: localStorage,
+ getLogin: () => planningOwnerLogin,
+ fetchJson: fetchReviewJson,
+ onRemoteRecords: records => {
+ if (!planningOwnerLogin || !laterWork.adopt(records)) return;
+ refreshMyWorkView();
+ },
+ onStatus: state => {
+ qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
+ (state === 'pending' ? 'Later saved on this device · sync pending.' :
+ 'Later sync unavailable · changes stay on this device.');
+ },
+ });
+ laterSync.startLifecycle({ window, document });
document.addEventListener('visibilitychange', () => {
if (!document.hidden) refreshMyWorkView();
});
@@ -2017,6 +2039,8 @@
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
+ laterSync.migrate(laterWork.read());
+ laterSync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating;
diff --git a/frontend/index.html b/frontend/index.html
index b9d43cc..7cb13bd 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -100,6 +100,7 @@
Load older updates
Today is saved on this device.
+ Later is saved on this device.
Retry shared work item
@@ -542,6 +543,7 @@
+
diff --git a/frontend/later-sync.js b/frontend/later-sync.js
new file mode 100644
index 0000000..ab7e3d4
--- /dev/null
+++ b/frontend/later-sync.js
@@ -0,0 +1,164 @@
+function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel }) {
+ const prefix = 'stackchain.later-sync.v1.';
+ const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
+ const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
+ let flushing = null;
+ let channel = null;
+ let channelKey = '';
+
+ function key() {
+ const login = String(getLogin?.() || '').trim().toLowerCase();
+ return login ? prefix + encodeURIComponent(login) : '';
+ }
+
+ function snapshotKey() {
+ const storageKey = key();
+ return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
+ }
+
+ function savedRevision() {
+ try {
+ const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
+ return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
+ } catch (_error) {
+ return -1;
+ }
+ }
+
+ function validRecords(records) {
+ return records && typeof records === 'object' && !Array.isArray(records);
+ }
+
+ function adopt(plan, broadcast = true) {
+ if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
+ if (plan.revision < savedRevision()) return false;
+ const snapshot = { revision: plan.revision, records: plan.records };
+ try {
+ storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
+ } catch (_error) {
+ // Server truth remains usable in this tab when storage is unavailable.
+ }
+ onRemoteRecords?.(plan.records);
+ if (broadcast) channel?.postMessage(snapshot);
+ return true;
+ }
+
+ function ensureChannel() {
+ const storageKey = key();
+ if (!storageKey || channelKey === storageKey) return;
+ channel?.close?.();
+ const factory = createChannel || (globalThis.window?.BroadcastChannel
+ ? name => new globalThis.window.BroadcastChannel(name)
+ : null);
+ channelKey = storageKey;
+ channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
+ channel?.addEventListener?.('message', event => {
+ if (key() === storageKey) adopt(event.data, false);
+ });
+ }
+
+ 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' &&
+ ['defer', 'restore'].includes(operation.action) &&
+ typeof operation.item_id === 'string' &&
+ (operation.action === 'restore' || typeof operation.wake_at === '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, wakeAt = null) {
+ if (!['defer', 'restore'].includes(action) || !itemId ||
+ (action === 'defer' && typeof wakeAt !== 'string')) return false;
+ const operations = pending().filter(operation => operation.item_id !== itemId);
+ operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
+ const saved = save(operations);
+ onStatus?.(saved ? 'pending' : 'error');
+ return saved;
+ }
+
+ function migrate(records) {
+ const storageKey = key();
+ if (!storageKey || !storage) return false;
+ const marker = migrationPrefix + storageKey.slice(prefix.length);
+ try {
+ if (storage.getItem(marker)) return false;
+ Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
+ enqueue('defer', itemId, wakeAt)
+ );
+ storage.setItem(marker, '1');
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ async function run() {
+ if (!key()) return false;
+ ensureChannel();
+ try {
+ let plan = await fetchJson('api/v1/later');
+ let operations = pending();
+ while (operations.length) {
+ const operation = operations[0];
+ plan = await fetchJson('api/v1/later', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(operation),
+ });
+ const remaining = pending();
+ const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
+ if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
+ throw new Error('Could not persist Later delivery receipt');
+ }
+ operations = pending();
+ }
+ adopt(plan);
+ 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;
+ }
+
+ function startLifecycle({ window: windowObject, document: documentObject }) {
+ windowObject?.addEventListener?.('online', flush);
+ documentObject?.addEventListener?.('visibilitychange', () =>
+ documentObject.hidden ? false : flush()
+ );
+ }
+
+ return { enqueue, migrate, flush, pending, startLifecycle };
+}
+
+if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
diff --git a/frontend/later-work.js b/frontend/later-work.js
index ebaacb5..89ae1cf 100644
--- a/frontend/later-work.js
+++ b/frontend/later-work.js
@@ -1,4 +1,4 @@
-function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
+function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
const prefix = 'stackchain.later-work.v1.';
let timer = null;
@@ -45,8 +45,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
if (!key) return 'unavailable';
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
const records = read();
- records[id] = wake.toISOString();
- return write(records) ? 'deferred' : 'unavailable';
+ const wakeAt = wake.toISOString();
+ records[id] = wakeAt;
+ if (!write(records)) return 'unavailable';
+ onChange('defer', id, wakeAt);
+ return 'deferred';
}
function presetUntil(preset) {
@@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const records = read();
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
delete records[id];
- write(records);
+ if (!write(records)) return false;
+ onChange('restore', id, null);
return true;
}
+ function adopt(records) {
+ if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
+ const normalized = {};
+ Object.entries(records).forEach(([id, wake]) => {
+ const wakeTime = new Date(wake).getTime();
+ if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
+ });
+ return write(normalized);
+ }
+
function schedule(wakeTimes, current) {
if (timer !== null) clearTimer(timer);
timer = null;
@@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
const available = new Map((items || []).map(item => [identity(item), item]));
const retained = {};
const wakeTimes = [];
+ const expired = [];
Object.entries(records).forEach(([id, wake]) => {
const wakeTime = new Date(wake).getTime();
- if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
+ if (!Number.isFinite(wakeTime) || wakeTime <= current) {
+ if (Number.isFinite(wakeTime)) expired.push(id);
+ return;
+ }
if (pruneMissing && !available.has(id)) return;
retained[id] = new Date(wakeTime).toISOString();
wakeTimes.push(wakeTime);
});
- if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
+ if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
+ onExpire(expired);
+ }
schedule(wakeTimes, current);
const deferredIds = new Set(Object.keys(retained));
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
@@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
- return { identity, defer, restore, presetUntil, partition };
+ return { identity, read, adopt, defer, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 792988f..231f909 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-v47';
+const CACHE = 'stackchain-dashboard-shell-v48';
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -28,6 +28,7 @@ const SHELL = [
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
+ BASE + 'static/later-sync.js',
BASE + 'static/detail-defer.js',
BASE + 'static/pick-work.js',
BASE + 'static/conversation.js',
diff --git a/src/later_store.py b/src/later_store.py
new file mode 100644
index 0000000..d93ea24
--- /dev/null
+++ b/src/later_store.py
@@ -0,0 +1,124 @@
+"""Durable, account-scoped Later deferrals."""
+
+import json
+import sqlite3
+from datetime import datetime
+from pathlib import Path
+
+
+class LaterStore:
+ def __init__(self, path: str | Path, *, timeout: float = 1.0):
+ self.path = Path(path)
+ 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 later_plans (
+ login TEXT PRIMARY KEY,
+ revision INTEGER NOT NULL,
+ records TEXT NOT NULL
+ )
+ """
+ )
+ connection.execute(
+ """
+ CREATE TABLE IF NOT EXISTS later_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, "records": {}}
+ return {"revision": int(row[0]), "records": json.loads(row[1])}
+
+ @staticmethod
+ def _validate_wake_at(wake_at: str | None) -> str:
+ if not wake_at:
+ raise ValueError("wake_at is required for defer")
+ try:
+ datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
+ except ValueError as error:
+ raise ValueError("wake_at must be an ISO timestamp") from error
+ return wake_at
+
+ def get(self, login: str) -> dict:
+ with self._connect() as connection:
+ row = connection.execute(
+ "SELECT revision, records FROM later_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,
+ *,
+ wake_at: 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 {"defer", "restore"}:
+ raise ValueError("unsupported Later action")
+ if action == "defer":
+ wake_at = self._validate_wake_at(wake_at)
+
+ with self._connect() as connection:
+ connection.execute("BEGIN IMMEDIATE")
+ row = connection.execute(
+ "SELECT revision, records FROM later_plans WHERE login = ?", (login,)
+ ).fetchone()
+ snapshot = self._snapshot(row)
+ duplicate = connection.execute(
+ "SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
+ (login, operation_id),
+ ).fetchone()
+ if duplicate:
+ return snapshot
+
+ records = dict(snapshot["records"])
+ before = records.get(item_id)
+ if action == "defer":
+ records[item_id] = wake_at
+ changed = before != wake_at
+ else:
+ changed = item_id in records
+ records.pop(item_id, None)
+
+ revision = snapshot["revision"] + (1 if changed else 0)
+ serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
+ if row is None:
+ connection.execute(
+ "INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
+ (login, revision, serialized),
+ )
+ elif changed:
+ connection.execute(
+ "UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
+ (revision, serialized, login),
+ )
+ connection.execute(
+ "INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
+ (login, operation_id),
+ )
+ return {"revision": revision, "records": records}
diff --git a/src/main.py b/src/main.py
index 297e491..14deb88 100644
--- a/src/main.py
+++ b/src/main.py
@@ -35,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.later_store import LaterStore
from src.today_store import TodayPlanFull, TodayStore
from src.views import router as frontend_router
@@ -216,6 +217,13 @@ class TodayOperation(BaseModel):
return self
+class LaterOperation(BaseModel):
+ operation_id: str = Field(min_length=1, max_length=100)
+ action: Literal["defer", "restore"]
+ item_id: str = Field(min_length=1, max_length=500)
+ wake_at: str | None = Field(default=None, max_length=100)
+
+
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@@ -633,7 +641,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", "/api/v1/today"} 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", "/api/v1/later"} or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@@ -861,6 +869,12 @@ def _today_store() -> TodayStore:
)
+def _later_store() -> LaterStore:
+ return LaterStore(
+ os.getenv("STACKCHAIN_LATER_DB", str(_state_dir / "later.sqlite3"))
+ )
+
+
async def _confirmed_login() -> str:
try:
user = await asyncio.wait_for(
@@ -907,6 +921,37 @@ async def update_today_plan(payload: TodayOperation):
)
+@app.get("/api/v1/later")
+async def get_later_plan():
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(_later_store().get, login)
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503, detail="Later synchronization is unavailable"
+ )
+
+
+@app.patch("/api/v1/later")
+async def update_later_plan(payload: LaterOperation):
+ login = await _confirmed_login()
+ try:
+ return await asyncio.to_thread(
+ _later_store().apply,
+ login,
+ payload.operation_id,
+ payload.action,
+ payload.item_id,
+ wake_at=payload.wake_at,
+ )
+ except ValueError as error:
+ raise HTTPException(status_code=422, detail=str(error))
+ except (OSError, sqlite3.Error):
+ raise HTTPException(
+ status_code=503, detail="Later synchronization is unavailable"
+ )
+
+
@app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session
diff --git a/tests/test_later_store.py b/tests/test_later_store.py
new file mode 100644
index 0000000..1d2f717
--- /dev/null
+++ b/tests/test_later_store.py
@@ -0,0 +1,94 @@
+import httpx
+import pytest
+
+from src import main
+from src.later_store import LaterStore
+
+
+def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
+ path = tmp_path / "later.sqlite3"
+ store = LaterStore(path)
+
+ deferred = store.apply(
+ "Timmy",
+ "op-1",
+ "defer",
+ "issue:stackchain/dashboard:363:",
+ wake_at="2026-08-10T09:00:00.000Z",
+ )
+ duplicate = store.apply(
+ "timmy",
+ "op-1",
+ "defer",
+ "issue:stackchain/dashboard:363:",
+ wake_at="2026-08-11T09:00:00.000Z",
+ )
+
+ assert duplicate == deferred == {
+ "revision": 1,
+ "records": {
+ "issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
+ },
+ }
+ assert LaterStore(path).get("timmy") == deferred
+ assert store.get("alexander") == {"revision": 0, "records": {}}
+
+ assert store.apply(
+ "timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
+ ) == {"revision": 2, "records": {}}
+
+
+@pytest.mark.anyio
+async def test_authenticated_later_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_LATER_DB", str(tmp_path / "later.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/later",
+ json={
+ "operation_id": "mobile-1",
+ "action": "defer",
+ "item_id": "issue:stackchain/dashboard:363:",
+ "wake_at": "2026-08-10T09:00:00.000Z",
+ },
+ )
+ changed = await client.patch(
+ "/api/v1/later",
+ json={
+ "operation_id": "mobile-1",
+ "action": "defer",
+ "item_id": "issue:stackchain/dashboard:363:",
+ "wake_at": "2026-08-10T09:00:00.000Z",
+ },
+ headers={
+ "Origin": "https://test",
+ "X-CSRF-Token": client.cookies["stackchain_csrf"],
+ },
+ )
+ fetched = await client.get("/api/v1/later")
+
+ assert forbidden.status_code == 403
+ assert changed.status_code == 200
+ assert changed.json() == fetched.json() == {
+ "revision": 1,
+ "records": {
+ "issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
+ },
+ }
+ assert fetched.headers["cache-control"] == "no-store"
diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py
new file mode 100644
index 0000000..5a693f1
--- /dev/null
+++ b/tests/test_later_sync.py
@@ -0,0 +1,210 @@
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from tests.dashboard_bundle import dashboard
+
+
+LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
+LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
+
+
+def run_node(script):
+ return json.loads(
+ subprocess.run(
+ ["node", "-e", script], check=True, capture_output=True, text=True
+ ).stdout
+ )
+
+
+def test_offline_deferral_replays_once_and_adopts_server_records():
+ script = f"""
+const createLaterSync = require({json.dumps(str(LATER_SYNC))});
+const values = new Map();
+const requests = [];
+let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
+const sync = createLaterSync({{
+ 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 (url,options={{}})=>{{
+ requests.push({{url,body:options.body&&JSON.parse(options.body)}});
+ if (!options.method) return remote;
+ const operation=JSON.parse(options.body);
+ remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
+ return remote;
+ }},
+ onRemoteRecords:records=>{{globalThis.records=records}},
+ onStatus:status=>{{globalThis.status=status}},
+}});
+sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
+(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
+ requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
+}}));}})();
+"""
+ result = run_node(script)
+
+ assert [request["url"] for request in result["requests"]] == [
+ "api/v1/later",
+ "api/v1/later",
+ "api/v1/later",
+ ]
+ assert result["requests"][1]["body"] == {
+ "operation_id": "offline-op",
+ "action": "defer",
+ "item_id": "issue:r:2:",
+ "wake_at": "2026-08-10T09:00:00.000Z",
+ }
+ assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
+ assert result["status"] == "saved"
+ assert result["pending"] == []
+
+
+def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
+ script = f"""
+const createLaterSync = require({json.dumps(str(LATER_SYNC))});
+const values=new Map(); let sequence=0;
+const sync=createLaterSync({{
+ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
+ fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
+}});
+sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
+sync.enqueue('restore','issue:r:2:');
+(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
+"""
+ assert run_node(script) == {
+ "pending": [
+ {
+ "operation_id": "op-2",
+ "action": "restore",
+ "item_id": "issue:r:2:",
+ "wake_at": None,
+ }
+ ],
+ "status": "pending",
+ }
+
+
+def test_change_queued_during_delivery_is_drained_before_flush_settles():
+ script = f"""
+const createLaterSync=require({json.dumps(str(LATER_SYNC))});
+const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
+const sync=createLaterSync({{
+ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
+ fetchJson:async (_url,options={{}})=>{{
+ if (!options.method) return {{revision:0,records:{{}}}};
+ const operation=JSON.parse(options.body); actions.push(operation.action);
+ if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
+ return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
+ }},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
+}});
+sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
+(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
+sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
+process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
+"""
+ assert run_node(script) == {
+ "actions": ["defer", "restore"],
+ "pending": [],
+ "records": {},
+ }
+
+
+def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
+ script = f"""
+const createLaterSync=require({json.dumps(str(LATER_SYNC))});
+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 listeners=[];
+const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
+let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
+const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
+const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
+(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
+resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
+resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
+process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
+"""
+ result = run_node(script)
+ assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
+ assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
+
+
+def test_existing_browser_records_migrate_once_and_lifecycle_replays():
+ script = f"""
+const createLaterSync=require({json.dumps(str(LATER_SYNC))});
+const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
+const sync=createLaterSync({{
+ 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 (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
+ onRemoteRecords:()=>{{}},onStatus:()=>{{}},
+}});
+const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
+const first=sync.migrate(records),second=sync.migrate(records);
+sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
+(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
+"""
+ assert run_node(script) == {
+ "first": True,
+ "second": False,
+ "requests": ["GET", "PATCH"],
+ "pending": [],
+ }
+
+
+def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
+ script = f"""
+const createLaterWork=require({json.dumps(str(LATER_WORK))});
+const values=new Map(); const changes=[]; const expired=[];
+let clock=new Date('2026-08-08T12:00:00Z');
+const item={{kind:'issue',repository:'stackchain/api',number:17}};
+const work=createLaterWork({{
+ storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
+ getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
+ onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
+ onExpire:ids=>expired.push(ids),
+}});
+work.defer(item,new Date('2026-08-08T16:00:00Z'));
+work.restore(item);
+work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
+const remote=work.partition([item]);
+clock=new Date('2026-08-09T09:00:01Z');
+const awake=work.partition([item]);
+process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
+"""
+ result = run_node(script)
+ assert result["changes"] == [
+ ["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
+ ["restore", "issue:stackchain/api:17:", None],
+ ]
+ assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
+ assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
+ assert result["expired"] == [["issue:stackchain/api:17:"]]
+
+
+@pytest.mark.anyio
+async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
+ html = await dashboard()
+
+ assert '' in html
+ assert 'id="later-sync-status"' in html
+ assert "const laterSync = createLaterSync({" in html
+ assert "onRemoteRecords: records =>" in html
+ assert "onChange: (action, itemId, wakeAt) =>" in html
+ assert "onExpire: ids =>" in html
+ assert "laterSync.enqueue(action, itemId, wakeAt)" in html
+ assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
+ assert "laterSync.migrate(laterWork.read());" in html
+ assert "laterSync.flush();" in html
+ assert "Later saved to account." in html
+
+
+def test_later_sync_ships_atomically_in_the_offline_shell():
+ source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
+
+ assert "stackchain-dashboard-shell-v48" in source
+ assert "BASE + 'static/later-sync.js'" in source
diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py
index b6d277e..a2e4f51 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-v47" in worker
+ assert "stackchain-dashboard-shell-v48" in worker
diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py
index 143773d..18a658f 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-v47" in worker
+ assert "stackchain-dashboard-shell-v48" in worker
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index 47f0310..8e774f9 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-v47" in source
+ assert "stackchain-dashboard-shell-v48" 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,21 +117,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v47" in source
+ assert "stackchain-dashboard-shell-v48" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
- assert "stackchain-dashboard-shell-v47" in source
+ assert "stackchain-dashboard-shell-v48" 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-v47" in source
+ assert "stackchain-dashboard-shell-v48" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -329,6 +329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
+ "/dashboard/static/later-sync.js",
"/dashboard/static/detail-defer.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/conversation.js",
diff --git a/tests/test_today_work.py b/tests/test_today_work.py
index bd61093..da001fa 100644
--- a/tests/test_today_work.py
+++ b/tests/test_today_work.py
@@ -185,7 +185,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") == 3
+ assert html.count("getLogin: () => planningOwnerLogin") == 4
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html