feat: sync Later deferrals across devices (#363)
All checks were successful
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-09 02:37:32 +00:00
parent 4bc2881a4b
commit 454022ee89
14 changed files with 706 additions and 19 deletions

View File

@ -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

View File

@ -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;

View File

@ -100,6 +100,7 @@
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
@ -542,6 +543,7 @@
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.js"></script>
<script src="static/detail-defer.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/conversation.js"></script>

164
frontend/later-sync.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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',

124
src/later_store.py Normal file
View File

@ -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}

View File

@ -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

94
tests/test_later_store.py Normal file
View File

@ -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"

210
tests/test_later_sync.py Normal file
View File

@ -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 '<script src="static/later-sync.js"></script>' 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

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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