feat: hand off active Today sessions across devices (Closes #1022)
CI / lint (pull_request) Successful in 3m11s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-17 12:44:01 +00:00
1 parent 51f4223f4b
commit 8d12a0384b
14 files changed
+596 -7

No files matched your search

+1
View File
@@ -988,6 +988,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js",
"/dashboard/static/today-handoff.js",
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
from src.frontend_bundle import build_frontend
ROOT = Path(__file__).parents[1]
def test_dashboard_packages_a_mobile_today_session_handoff():
build = build_frontend(ROOT / "frontend")
html = build.dashboard_html
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
today_bundle = build.feature_bundles["today-timer"].runtime_bytes.decode()
assert 'id="today-session-handoff"' in html
assert 'id="continue-today-session"' in html
assert 'aria-live="polite"' in html
assert "createTodaySessionSync" in today_bundle
assert "attachTodaySessionHandoff" in today_bundle
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
assert "todaySessionSync?.publish(snapshot)" in dashboard
assert "min-height:44px" in css
+121
View File
@@ -0,0 +1,121 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"
def run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", SOURCE.read_text() + "\n" + script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_second_device_sees_and_claims_the_running_session():
result = run_node(
r"""
const calls = [];
const remote = {revision:3, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:90000, running:true, updated_at:10};
let adopted = null;
const offers = [];
const sync = createTodaySessionSync({
getDeviceId:()=>'phone-b',
fetchJson:async (url, options={}) => {
calls.push({url, body:options.body ? JSON.parse(options.body) : null});
return options.method === 'PATCH' ? {...remote, revision:4, device_id:'phone-b'} : remote;
},
timer:{adopt:(identity, elapsed, running)=>{adopted={identity, elapsed, running};}},
onRemote:session=>{offers.push(session);},
});
(async()=>{
await sync.refresh();
const claimed = await sync.claim();
process.stdout.write(JSON.stringify({offers, claimed, adopted, calls}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
)
assert result["offers"][0]["device_id"] == "desktop-a"
assert result["claimed"]["device_id"] == "phone-b"
assert result["adopted"] == {
"identity": "issue:r:42:", "elapsed": 90000, "running": True,
}
assert result["calls"][1] == {
"url": "api/v1/today/session",
"body": {
"base_revision": 3,
"device_id": "phone-b",
"identity": "issue:r:42:",
"elapsed_ms": 90000,
"running": True,
},
}
def test_previous_owner_pauses_after_another_device_claims():
result = run_node(
r"""
let response = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
let pauses = 0;
let transferred = null;
const sync = createTodaySessionSync({
getDeviceId:()=> 'desktop-a',
fetchJson:async()=>response,
timer:{pause:()=>{pauses += 1;}},
onTransferred:session=>{transferred=session;},
});
(async()=>{
await sync.refresh();
response = {...response, revision:2, device_id:'phone-b', elapsed_ms:2500};
await sync.refresh();
await sync.refresh();
process.stdout.write(JSON.stringify({pauses, transferred}));
})().catch(error=>{console.error(error);process.exit(1);});
"""
)
assert result["pauses"] == 1
assert result["transferred"]["device_id"] == "phone-b"
def test_owner_poll_publishes_current_elapsed_time():
result = run_node(
r"""
const calls = [];
let tick = null;
let elapsed = 1000;
const owned = {revision:1, device_id:'desktop-a', identity:'issue:r:42:', elapsed_ms:1000, running:true};
const sync = createTodaySessionSync({
getDeviceId:()=> 'desktop-a',
fetchJson:async (url, options={}) => {
calls.push({method:options.method || 'GET', body:options.body ? JSON.parse(options.body) : null});
return options.method === 'PATCH' ? {...owned, revision:2, elapsed_ms:elapsed} : owned;
},
timer:{snapshot:()=>({identity:owned.identity, elapsed_ms:elapsed, running:true})},
setInterval:callback=>{tick=callback; return 7;},
});
(async()=>{
await sync.refresh();
sync.start();
elapsed = 6500;
await tick();
process.stdout.write(JSON.stringify(calls));
})().catch(error=>{console.error(error);process.exit(1);});
"""
)
assert result[-1] == {
"method": "PATCH",
"body": {
"base_revision": 1,
"device_id": "desktop-a",
"identity": "issue:r:42:",
"elapsed_ms": 6500,
"running": True,
},
}
+92 -1
View File
@@ -4,7 +4,80 @@ import pytest
import httpx
from src import main
from src.today_store import TodayPlanFull, TodayStore
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
def test_active_session_is_durable_and_account_scoped(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, clock=lambda: 1234.5)
saved = store.update_session(
"Timmy", base_revision=0, device_id="phone-a",
identity="issue:stackchain/dashboard:42:", elapsed_ms=90_000, running=True,
)
assert saved == {
"revision": 1,
"device_id": "phone-a",
"identity": "issue:stackchain/dashboard:42:",
"elapsed_ms": 90_000,
"running": True,
"updated_at": 1234.5,
}
assert TodayStore(path).get_session("timmy") == saved
assert store.get_session("alexander") == {
"revision": 0,
"device_id": "",
"identity": "",
"elapsed_ms": 0,
"running": False,
"updated_at": None,
}
def test_active_session_claim_rejects_a_stale_revision(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3")
first = store.update_session(
"timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
elapsed_ms=1_000, running=True,
)
claimed = store.update_session(
"timmy", base_revision=first["revision"], device_id="phone-b",
identity=first["identity"], elapsed_ms=first["elapsed_ms"], running=True,
)
with pytest.raises(TodaySessionConflict) as raised:
store.update_session(
"timmy", base_revision=first["revision"], device_id="phone-a",
identity=first["identity"], elapsed_ms=5_000, running=True,
)
assert raised.value.session == claimed
assert store.get_session("timmy") == claimed
@pytest.mark.anyio
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
async def user():
return {"login": "timmy"}
store = TodayStore(tmp_path / "today.sqlite3")
current = store.update_session(
"timmy", base_revision=0, device_id="phone-a", identity="issue:r:1:",
elapsed_ms=5_000, running=True,
)
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: store)
payload = main.TodaySessionUpdate(
base_revision=0, device_id="phone-b", identity="issue:r:1:",
elapsed_ms=5_000, running=True,
)
with pytest.raises(main.HTTPException) as raised:
await main.update_today_session(payload)
assert raised.value.status_code == 409
assert raised.value.detail == {"code": "session_changed", "session": current}
@pytest.mark.anyio
@@ -342,6 +415,21 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
},
)
fetched = await client.get("/api/v1/today")
changed_session = await client.patch(
"/api/v1/today/session",
json={
"base_revision": 0,
"device_id": "phone-a",
"identity": "issue:stackchain/dashboard:357:",
"elapsed_ms": 12_000,
"running": True,
},
headers={
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
},
)
fetched_session = await client.get("/api/v1/today/session")
assert forbidden.status_code == 403
assert changed.status_code == 200
@@ -361,3 +449,6 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
"estimates": {},
}
assert fetched.headers["cache-control"] == "no-store"
assert changed_session.status_code == 200
assert fetched_session.json() == changed_session.json()
assert fetched_session.headers["cache-control"] == "no-store"
+51
View File
@@ -0,0 +1,51 @@
import json
import subprocess
from pathlib import Path
SOURCE = Path(__file__).parents[1] / "frontend" / "today-timer.js"
def test_timer_adopts_claimed_elapsed_time():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {
getItem:key=>values.get(key)||null,
setItem:(key,value)=>values.set(key,value),
};
let now = 100000;
const timer = createTodayTimer({storage, getLogin:()=> 'timmy', now:()=>now});
const adopted = timer.adopt('issue:r:42:', 90000, true);
now = 105000;
process.stdout.write(JSON.stringify({adopted, snapshot:timer.snapshot()}));
"""
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
result = json.loads(completed.stdout)
assert result == {
"adopted": True,
"snapshot": {"identity": "issue:r:42:", "elapsed_ms": 95000, "running": True},
}
def test_timer_reports_durable_changes_for_session_sync():
script = SOURCE.read_text() + r"""
const values = new Map();
const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
let now = 0;
const changes = [];
const timer = createTodayTimer({
storage, getLogin:()=> 'timmy', now:()=>now,
onChange:snapshot=>changes.push(snapshot),
});
timer.activate('issue:r:42:');
now = 5000;
timer.pause();
process.stdout.write(JSON.stringify(changes));
"""
completed = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
assert json.loads(completed.stdout) == [
{"identity": "issue:r:42:", "elapsed_ms": 0, "running": True},
{"identity": "issue:r:42:", "elapsed_ms": 5000, "running": False},
]