From c5b466f2816fcde81ce6dfd49e46a792a9abd2ef Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 20:08:05 +0000 Subject: [PATCH 1/2] feat: continue timed breaks across devices (Closes #1038) --- frontend/today-session-sync.js | 52 ++++++++++++++++++++++---------- frontend/today-timer.js | 15 ++++++--- src/main.py | 1 + src/today_store.py | 30 ++++++++++-------- tests/test_today_break.py | 21 ++++++++++++- tests/test_today_session_sync.py | 50 ++++++++++++++++++++++++++++++ tests/test_today_store.py | 36 ++++++++++++++++++++++ 7 files changed, 172 insertions(+), 33 deletions(-) diff --git a/frontend/today-session-sync.js b/frontend/today-session-sync.js index f96c90e..e0ebdd3 100644 --- a/frontend/today-session-sync.js +++ b/frontend/today-session-sync.js @@ -20,12 +20,13 @@ function createTodaySessionSync({ function adopt(session) { if (!session || !Number.isInteger(session.revision)) return null; - const previousOwned = current?.device_id === deviceId() && current?.running; + const previousOwned = current?.device_id === deviceId() && + (current?.running || Number.isFinite(current?.break_deadline_at)); current = session; if (session.device_id === deviceId()) { ownedRevision = session.revision; onRemote(null); - } else if (session.running && session.identity) { + } else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) { if (previousOwned) { timer?.pause?.(); onTransferred(session); @@ -53,15 +54,18 @@ function createTodaySessionSync({ function claim() { return serialize(async () => { - if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null; + if ((!current?.running && !Number.isFinite(current?.break_deadline_at)) || + !current.identity || current.device_id === deviceId() || !deviceId()) return null; try { onStatus('syncing'); + const body = { + base_revision:current.revision, device_id:deviceId(), identity:current.identity, + elapsed_ms:current.elapsed_ms, running:true, + }; + if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null; const session = await fetchJson(endpoint, { method:'PATCH', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({ - base_revision:current.revision, device_id:deviceId(), identity:current.identity, - elapsed_ms:current.elapsed_ms, running:true, - }), + body:JSON.stringify(body), }); adopt(session); timer?.adopt?.(session.identity, session.elapsed_ms, session.running); @@ -82,13 +86,18 @@ function createTodaySessionSync({ if (!snapshot?.identity || !deviceId()) return null; try { onStatus('syncing'); + const body = { + base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity, + elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)), + running:Boolean(snapshot.running), + }; + if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) { + body.break_deadline_at = Number.isFinite(snapshot.break_deadline_at) ? + Math.floor(snapshot.break_deadline_at) : null; + } const session = await fetchJson(endpoint, { method:'PATCH', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({ - base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity, - elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)), - running:Boolean(snapshot.running), - }), + body:JSON.stringify(body), }); adopt(session); return session; @@ -146,6 +155,17 @@ function createTodaySessionSync({ }; } +function todaySessionHandoffSummary(session, item) { + if (Number.isFinite(session?.break_deadline_at)) { + const end = new Date(session.break_deadline_at).toLocaleTimeString([], { + hour:'numeric', minute:'2-digit', + }); + return `On break until ${end} · ready to resume here`; + } + const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000)); + return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`; +} + function attachTodaySessionHandoff({ fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer, }) { @@ -176,9 +196,9 @@ function attachTodaySessionHandoff({ handoff.hidden = !session; if (!session) return; const item = items().find(entry => identity(entry) === session.identity); - const minutes = Math.max(0, Math.floor(Number(session.elapsed_ms || 0) / 60000)); - qs('#today-session-handoff-summary').textContent = - `${item?.title || 'Current Today item'} · ${minutes} min elapsed`; + qs('#today-session-handoff-summary').textContent = todaySessionHandoffSummary(session, item); + qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ? + 'Resume Today here' : 'Continue here'; }, onTransferred:() => { showSessionStatus('Session continued on another device.'); @@ -201,5 +221,5 @@ function attachTodaySessionHandoff({ } if (typeof module !== 'undefined' && module.exports) module.exports = { - createTodaySessionSync, attachTodaySessionHandoff, + createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary, }; diff --git a/frontend/today-timer.js b/frontend/today-timer.js index 180085d..57c21f9 100644 --- a/frontend/today-timer.js +++ b/frontend/today-timer.js @@ -61,10 +61,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange const state = read(); const selected = identity || state.active_identity; const entry = state.entries[selected]; - if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false }; - const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ? - Math.max(0, now() - Number(entry.started_at ?? now())) : 0); - return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) }; + const result = !selected || !entry ? + { identity:selected, elapsed_ms:0, running:false } : + { + identity:selected, + elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ? + Math.max(0, now() - Number(entry.started_at ?? now())) : 0), + running:Boolean(entry.running), + }; + const timedBreak = validBreak(state); + if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at; + return result; }; return { adopt(identity, elapsedMs, running) { diff --git a/src/main.py b/src/main.py index 449b7e8..bf4fecb 100644 --- a/src/main.py +++ b/src/main.py @@ -594,6 +594,7 @@ class TodaySessionUpdate(BaseModel): identity: str = Field(max_length=500) elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000) running: bool + break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000) class TodayRecapTimeLog(TodayRecap): diff --git a/src/today_store.py b/src/today_store.py index e668837..0ac0784 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -44,7 +44,7 @@ class TodayStore: def _initialize(self) -> None: connection = connect_private_sqlite(self.path, timeout=self.timeout) - if connection.execute("PRAGMA user_version").fetchone()[0] >= 2: + if connection.execute("PRAGMA user_version").fetchone()[0] >= 3: connection.close() return connection.execute("PRAGMA journal_mode=WAL") @@ -123,11 +123,15 @@ class TodayStore: identity TEXT NOT NULL, elapsed_ms INTEGER NOT NULL, running INTEGER NOT NULL, + break_deadline_at INTEGER, updated_at REAL NOT NULL ) """ ) - connection.execute("PRAGMA user_version = 2") + session_columns = {row[1] for row in connection.execute("PRAGMA table_info(today_sessions)")} + if "break_deadline_at" not in session_columns: + connection.execute("ALTER TABLE today_sessions ADD COLUMN break_deadline_at INTEGER") + connection.execute("PRAGMA user_version = 3") connection.commit() connection.close() @@ -188,13 +192,13 @@ class TodayStore: def _empty_session() -> dict: return { "revision": 0, "device_id": "", "identity": "", - "elapsed_ms": 0, "running": False, "updated_at": None, + "elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None, } def get_session(self, login: str) -> dict: with self._connect() as connection: row = connection.execute( - "SELECT revision, device_id, identity, elapsed_ms, running, updated_at " + "SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at " "FROM today_sessions WHERE login = ?", (self._normalize_login(login),), ).fetchone() @@ -202,36 +206,38 @@ class TodayStore: return self._empty_session() return { "revision": int(row[0]), "device_id": row[1], "identity": row[2], - "elapsed_ms": int(row[3]), "running": bool(row[4]), "updated_at": row[5], + "elapsed_ms": int(row[3]), "running": bool(row[4]), + "break_deadline_at": row[5], "updated_at": row[6], } def update_session( self, login: str, *, base_revision: int, device_id: str, - identity: str, elapsed_ms: int, running: bool, + identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None, ) -> dict: login = self._normalize_login(login) updated_at = self.clock() with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") current = connection.execute( - "SELECT revision, device_id, identity, elapsed_ms, running, updated_at " + "SELECT revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at " "FROM today_sessions WHERE login = ?", (login,) ).fetchone() current_revision = int(current[0]) if current else 0 if base_revision != current_revision: session = self._empty_session() if current is None else { "revision": current_revision, "device_id": current[1], "identity": current[2], - "elapsed_ms": int(current[3]), "running": bool(current[4]), "updated_at": current[5], + "elapsed_ms": int(current[3]), "running": bool(current[4]), + "break_deadline_at": current[5], "updated_at": current[6], } raise TodaySessionConflict(session) revision = current_revision + 1 connection.execute( - "INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?) " + "INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, " "identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, " - "updated_at=excluded.updated_at", - (login, revision, device_id, identity, elapsed_ms, int(running), updated_at), + "break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at", + (login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at), ) return self.get_session(login) diff --git a/tests/test_today_break.py b/tests/test_today_break.py index 17c9e43..f57ae08 100644 --- a/tests/test_today_break.py +++ b/tests/test_today_break.py @@ -37,7 +37,10 @@ process.stdout.write(JSON.stringify({started,paused,restored})); "deadline_at": 1012000, "expired": False, }, - "paused": {"identity": "issue:r:42:", "elapsed_ms": 12000, "running": False}, + "paused": { + "identity": "issue:r:42:", "elapsed_ms": 12000, "running": False, + "break_deadline_at": 1012000, + }, "restored": { "identity": "issue:r:42:", "deadline_at": 1012000, @@ -46,6 +49,22 @@ process.stdout.write(JSON.stringify({started,paused,restored})); } +def test_timed_break_snapshot_carries_deadline_for_cross_device_sync(): + script = TIMER.read_text() + r""" +const values = new Map(); +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +const timer = createTodayTimer({storage,getLogin:()=> 'timmy',now:()=>100000}); +timer.activate('issue:r:42:'); +timer.startBreak(15); +process.stdout.write(JSON.stringify(timer.snapshot())); +""" + + assert run_node(script) == { + "identity": "issue:r:42:", "elapsed_ms": 0, "running": False, + "break_deadline_at": 1_000_000, + } + + def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent(): script = TIMER.read_text() + r""" const values = new Map(); diff --git a/tests/test_today_session_sync.py b/tests/test_today_session_sync.py index 17437df..faa8c66 100644 --- a/tests/test_today_session_sync.py +++ b/tests/test_today_session_sync.py @@ -57,6 +57,56 @@ const sync = createTodaySessionSync({ } +def test_second_device_sees_and_resumes_a_timed_break(): + result = run_node( + r""" +const calls=[]; +const remote={revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000,updated_at:10}; +let adopted=null; +const offers=[]; +const sync=createTodaySessionSync({ + getDeviceId:()=> 'desktop-b', + fetchJson:async (url,options={})=>{ + calls.push(options.body ? JSON.parse(options.body) : null); + return options.method === 'PATCH' ? {...remote,revision:4,device_id:'desktop-b',running:true,break_deadline_at:null} : 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]["break_deadline_at"] == 1_800_000 + assert result["calls"][1] == { + "base_revision": 3, "device_id": "desktop-b", "identity": "issue:r:42:", + "elapsed_ms": 90_000, "running": True, "break_deadline_at": None, + } + assert result["adopted"] == { + "identity": "issue:r:42:", "elapsed": 90_000, "running": True, + } + assert result["claimed"]["device_id"] == "desktop-b" + + +def test_break_handoff_summary_is_actionable_and_privacy_safe(): + result = run_node( + r""" +const session={identity:'issue:private/repo:42:',elapsed_ms:90000,running:false,break_deadline_at:1800000}; +process.stdout.write(JSON.stringify({ + summary:todaySessionHandoffSummary(session,{title:'Secret issue'},0), +})); +""" + ) + + assert result["summary"].startswith("On break until ") + assert "Secret issue" not in result["summary"] + assert "private/repo" not in result["summary"] + + def test_previous_owner_pauses_after_another_device_claims(): result = run_node( r""" diff --git a/tests/test_today_store.py b/tests/test_today_store.py index 3ddf0a0..b69bf30 100644 --- a/tests/test_today_store.py +++ b/tests/test_today_store.py @@ -22,6 +22,7 @@ def test_active_session_is_durable_and_account_scoped(tmp_path): "identity": "issue:stackchain/dashboard:42:", "elapsed_ms": 90_000, "running": True, + "break_deadline_at": None, "updated_at": 1234.5, } assert TodayStore(path).get_session("timmy") == saved @@ -31,10 +32,26 @@ def test_active_session_is_durable_and_account_scoped(tmp_path): "identity": "", "elapsed_ms": 0, "running": False, + "break_deadline_at": None, "updated_at": None, } +def test_timed_break_deadline_is_durable_in_the_account_session(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:r:42:", elapsed_ms=90_000, running=False, + break_deadline_at=1_800_000, + ) + + assert saved["break_deadline_at"] == 1_800_000 + assert saved["running"] is False + assert TodayStore(path).get_session("timmy") == saved + + def test_active_session_claim_rejects_a_stale_revision(tmp_path): store = TodayStore(tmp_path / "today.sqlite3") first = store.update_session( @@ -56,6 +73,25 @@ def test_active_session_claim_rejects_a_stale_revision(tmp_path): assert store.get_session("timmy") == claimed +@pytest.mark.anyio +async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch, tmp_path): + async def user(): + return {"login": "timmy"} + + store = TodayStore(tmp_path / "today.sqlite3") + monkeypatch.setattr(main, "current_user", user) + monkeypatch.setattr(main, "_today_store", lambda: store) + payload = main.TodaySessionUpdate( + base_revision=0, device_id="phone-a", identity="issue:r:1:", + elapsed_ms=5_000, running=False, break_deadline_at=1_800_000, + ) + + saved = await main.update_today_session(payload) + + assert saved["break_deadline_at"] == 1_800_000 + assert (await main.get_today_session()) == saved + + @pytest.mark.anyio async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path): async def user(): From dfd33aee1c033b8de370fdcff3617525502af260 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 20:17:43 +0000 Subject: [PATCH 2/2] fix: reject contradictory running break sessions --- src/main.py | 6 ++++++ tests/test_today_store.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/src/main.py b/src/main.py index bf4fecb..45be49b 100644 --- a/src/main.py +++ b/src/main.py @@ -596,6 +596,12 @@ class TodaySessionUpdate(BaseModel): running: bool break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000) + @model_validator(mode="after") + def validate_break_is_paused(self): + if self.running and self.break_deadline_at is not None: + raise ValueError("break must remain paused") + return self + class TodayRecapTimeLog(TodayRecap): log_identities: list[str] = Field(min_length=1, max_length=20) diff --git a/tests/test_today_store.py b/tests/test_today_store.py index b69bf30..90034f3 100644 --- a/tests/test_today_store.py +++ b/tests/test_today_store.py @@ -92,6 +92,14 @@ async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch, assert (await main.get_today_session()) == saved +def test_active_session_api_rejects_a_running_break(): + with pytest.raises(ValueError, match="break must remain paused"): + main.TodaySessionUpdate( + base_revision=0, device_id="phone-a", identity="issue:r:1:", + elapsed_ms=5_000, running=True, break_deadline_at=1_800_000, + ) + + @pytest.mark.anyio async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path): async def user():