Sync the ordered Today plan across operator devices #358

Merged
timmy merged 1 commits from timmy/357-cross-device-today into main 2026-08-09 00:40:47 +00:00
13 changed files with 640 additions and 13 deletions

View File

@ -139,6 +139,23 @@
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, 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({ const laterWork = createLaterWork({
storage: localStorage, storage: localStorage,
getLogin: () => planningOwnerLogin, getLogin: () => planningOwnerLogin,
@ -424,7 +441,11 @@
addToday: item => { addToday: item => {
const result = todayWork.add(item); const result = todayWork.add(item);
refreshMyWorkView(); refreshMyWorkView();
if (result === 'added') warmTodayOffline(); if (result === 'added') {
todaySync.enqueue('add', todayWork.identity(item));
todaySync.flush();
warmTodayOffline();
}
return result; return result;
}, },
onClaimed: item => { onClaimed: item => {
@ -1077,19 +1098,32 @@
(result === 'added' ? 'Added to Today without changing Gitea.' : (result === 'added' ? 'Added to Today without changing Gitea.' :
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.')); (result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
refreshMyWorkView(); 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 => { document.querySelectorAll('[data-today-remove]').forEach(button => {
button.addEventListener('click', () => { 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.'; qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
refreshMyWorkView(); refreshMyWorkView();
}); });
}); });
document.querySelectorAll('[data-today-move]').forEach(button => { document.querySelectorAll('[data-today-move]').forEach(button => {
button.addEventListener('click', () => { 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(); refreshMyWorkView();
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus(); 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() : ''; String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin; planningOwnerLogin = retainedPlanningLogin;
updatePlanningAvailability(); updatePlanningAvailability();
if (planningOwnerLogin) {
todaySync.migrate(todayWork.read());
todaySync.flush();
}
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale && const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
!contextFreshness?.degraded && !contextFreshness?.revalidating; !contextFreshness?.degraded && !contextFreshness?.revalidating;
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : ''; activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';

View File

@ -99,6 +99,7 @@
<div class="small" id="notification-page-status" aria-live="polite"></div> <div class="small" id="notification-page-status" aria-live="polite"></div>
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button> <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="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>
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button> <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="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden> <div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
@ -538,6 +539,7 @@
<script src="static/offline-today.js"></script> <script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script> <script src="static/my-work.js"></script>
<script src="static/today-work.js"></script> <script src="static/today-work.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script> <script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script> <script src="static/later-work.js"></script>
<script src="static/detail-defer.js"></script> <script src="static/detail-defer.js"></script>

View File

@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname; const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js'); 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 OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [ const SHELL = [
@ -25,6 +25,7 @@ const SHELL = [
BASE + 'static/offline-today.js', BASE + 'static/offline-today.js',
BASE + 'static/my-work.js', BASE + 'static/my-work.js',
BASE + 'static/today-work.js', BASE + 'static/today-work.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js', BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js', BASE + 'static/later-work.js',
BASE + 'static/detail-defer.js', BASE + 'static/detail-defer.js',

106
frontend/today-sync.js Normal file
View File

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

View File

@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable'; 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) { function remove(item) {
const id = identity(item); const id = identity(item);
const ids = read(); 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; if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;

View File

@ -2,6 +2,7 @@ import asyncio
import hmac import hmac
import math import math
import os import os
import sqlite3
import time import time
from collections.abc import Awaitable, Coroutine from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager 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.models import Issue, Milestone, PullRequest, Repo, User
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute from src.suggestion_engine import compute
from src.today_store import TodayPlanFull, TodayStore
from src.views import router as frontend_router from src.views import router as frontend_router
@asynccontextmanager @asynccontextmanager
@ -199,6 +201,21 @@ class NotificationReadBatch(BaseModel):
ids: list[PositiveInt] = Field(min_length=1, max_length=50) 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): class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000) 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): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) response = await call_next(request)
path = dashboard_auth.application_path(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/") path.startswith("/api/v1/repos/")
and path.endswith("/review") and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or ( ) 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") @app.delete("/api/v1/session")
async def sign_out(request: Request, response: Response): async def sign_out(request: Request, response: Response):
session = request.state.dashboard_session session = request.state.dashboard_session

130
src/today_store.py Normal file
View File

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

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 { 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 pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" 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

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])) 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 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

View File

@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text() 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.css'" in source
assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.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(): def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text() 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 assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text() 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 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/offline-today.js",
"/dashboard/static/my-work.js", "/dashboard/static/my-work.js",
"/dashboard/static/today-work.js", "/dashboard/static/today-work.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js", "/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js", "/dashboard/static/later-work.js",
"/dashboard/static/detail-defer.js", "/dashboard/static/detail-defer.js",

105
tests/test_today_store.py Normal file
View File

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

142
tests/test_today_sync.py Normal file
View File

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

View File

@ -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 @pytest.mark.anyio
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow(): async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
html = await dashboard() html = await dashboard()
assert '<script src="static/today-work.js"></script>' in html assert '<script src="static/today-work.js"></script>' in html
assert '<script src="static/today-sync.js"></script>' in html
assert 'data-work-filter="today"' in html assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" 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 'data-today-move="down"' in html
assert 'Today is limited to 5 items' in html assert 'Today is limited to 5 items' in html
assert '.today-actions button' in html and 'min-height:44px' 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 @pytest.mark.anyio
@ -101,7 +124,7 @@ async def test_retained_authenticated_context_keeps_local_planning_separate_from
html = await dashboard() html = await dashboard()
assert "let planningOwnerLogin = '';" in html 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 "const retainedPlanningLogin = !snapshot.context.error ?" in html
assert "planningOwnerLogin = retainedPlanningLogin;" in html assert "planningOwnerLogin = retainedPlanningLogin;" in html
assert "button.disabled = !planningOwnerLogin;" in html assert "button.disabled = !planningOwnerLogin;" in html