Merge pull request 'Continue a timed Today break across devices' (#1039) from timmy/1038-cross-device-today-break into main
Some checks failed
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Failing after 2m10s
CI / release-candidate (push) Has been skipped

This commit is contained in:
rockachopa 2026-08-17 20:23:06 +00:00
commit 4a85f02eae
7 changed files with 186 additions and 33 deletions

View File

@ -20,12 +20,13 @@ function createTodaySessionSync({
function adopt(session) { function adopt(session) {
if (!session || !Number.isInteger(session.revision)) return null; 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; current = session;
if (session.device_id === deviceId()) { if (session.device_id === deviceId()) {
ownedRevision = session.revision; ownedRevision = session.revision;
onRemote(null); onRemote(null);
} else if (session.running && session.identity) { } else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
if (previousOwned) { if (previousOwned) {
timer?.pause?.(); timer?.pause?.();
onTransferred(session); onTransferred(session);
@ -53,15 +54,18 @@ function createTodaySessionSync({
function claim() { function claim() {
return serialize(async () => { 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 { try {
onStatus('syncing'); 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, { const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'}, method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ body:JSON.stringify(body),
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
elapsed_ms:current.elapsed_ms, running:true,
}),
}); });
adopt(session); adopt(session);
timer?.adopt?.(session.identity, session.elapsed_ms, session.running); timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
@ -82,13 +86,18 @@ function createTodaySessionSync({
if (!snapshot?.identity || !deviceId()) return null; if (!snapshot?.identity || !deviceId()) return null;
try { try {
onStatus('syncing'); 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, { const session = await fetchJson(endpoint, {
method:'PATCH', headers:{'Content-Type':'application/json'}, method:'PATCH', headers:{'Content-Type':'application/json'},
body:JSON.stringify({ body:JSON.stringify(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),
}),
}); });
adopt(session); adopt(session);
return 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({ function attachTodaySessionHandoff({
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer, fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
}) { }) {
@ -176,9 +196,9 @@ function attachTodaySessionHandoff({
handoff.hidden = !session; handoff.hidden = !session;
if (!session) return; if (!session) return;
const item = items().find(entry => identity(entry) === session.identity); 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 = todaySessionHandoffSummary(session, item);
qs('#today-session-handoff-summary').textContent = qs('#continue-today-session').textContent = Number.isFinite(session.break_deadline_at) ?
`${item?.title || 'Current Today item'} · ${minutes} min elapsed`; 'Resume Today here' : 'Continue here';
}, },
onTransferred:() => { onTransferred:() => {
showSessionStatus('Session continued on another device.'); showSessionStatus('Session continued on another device.');
@ -201,5 +221,5 @@ function attachTodaySessionHandoff({
} }
if (typeof module !== 'undefined' && module.exports) module.exports = { if (typeof module !== 'undefined' && module.exports) module.exports = {
createTodaySessionSync, attachTodaySessionHandoff, createTodaySessionSync, attachTodaySessionHandoff, todaySessionHandoffSummary,
}; };

View File

@ -61,10 +61,17 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
const state = read(); const state = read();
const selected = identity || state.active_identity; const selected = identity || state.active_identity;
const entry = state.entries[selected]; const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false }; const result = !selected || !entry ?
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ? { identity:selected, elapsed_ms:0, running:false } :
Math.max(0, now() - Number(entry.started_at ?? now())) : 0); {
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) }; 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 { return {
adopt(identity, elapsedMs, running) { adopt(identity, elapsedMs, running) {

View File

@ -594,6 +594,13 @@ class TodaySessionUpdate(BaseModel):
identity: str = Field(max_length=500) identity: str = Field(max_length=500)
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000) elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
running: bool 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): class TodayRecapTimeLog(TodayRecap):

View File

@ -44,7 +44,7 @@ class TodayStore:
def _initialize(self) -> None: def _initialize(self) -> None:
connection = connect_private_sqlite(self.path, timeout=self.timeout) 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() connection.close()
return return
connection.execute("PRAGMA journal_mode=WAL") connection.execute("PRAGMA journal_mode=WAL")
@ -123,11 +123,15 @@ class TodayStore:
identity TEXT NOT NULL, identity TEXT NOT NULL,
elapsed_ms INTEGER NOT NULL, elapsed_ms INTEGER NOT NULL,
running INTEGER NOT NULL, running INTEGER NOT NULL,
break_deadline_at INTEGER,
updated_at REAL NOT NULL 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.commit()
connection.close() connection.close()
@ -188,13 +192,13 @@ class TodayStore:
def _empty_session() -> dict: def _empty_session() -> dict:
return { return {
"revision": 0, "device_id": "", "identity": "", "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: def get_session(self, login: str) -> dict:
with self._connect() as connection: with self._connect() as connection:
row = connection.execute( 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 = ?", "FROM today_sessions WHERE login = ?",
(self._normalize_login(login),), (self._normalize_login(login),),
).fetchone() ).fetchone()
@ -202,36 +206,38 @@ class TodayStore:
return self._empty_session() return self._empty_session()
return { return {
"revision": int(row[0]), "device_id": row[1], "identity": row[2], "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( def update_session(
self, login: str, *, base_revision: int, device_id: str, 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: ) -> dict:
login = self._normalize_login(login) login = self._normalize_login(login)
updated_at = self.clock() updated_at = self.clock()
with self._connect() as connection: with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE") connection.execute("BEGIN IMMEDIATE")
current = connection.execute( 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,) "FROM today_sessions WHERE login = ?", (login,)
).fetchone() ).fetchone()
current_revision = int(current[0]) if current else 0 current_revision = int(current[0]) if current else 0
if base_revision != current_revision: if base_revision != current_revision:
session = self._empty_session() if current is None else { session = self._empty_session() if current is None else {
"revision": current_revision, "device_id": current[1], "identity": current[2], "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) raise TodaySessionConflict(session)
revision = current_revision + 1 revision = current_revision + 1
connection.execute( connection.execute(
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, updated_at) " "INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, break_deadline_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(login) DO UPDATE SET revision=excluded.revision, device_id=excluded.device_id, " "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, " "identity=excluded.identity, elapsed_ms=excluded.elapsed_ms, running=excluded.running, "
"updated_at=excluded.updated_at", "break_deadline_at=excluded.break_deadline_at, updated_at=excluded.updated_at",
(login, revision, device_id, identity, elapsed_ms, int(running), updated_at), (login, revision, device_id, identity, elapsed_ms, int(running), break_deadline_at, updated_at),
) )
return self.get_session(login) return self.get_session(login)

View File

@ -37,7 +37,10 @@ process.stdout.write(JSON.stringify({started,paused,restored}));
"deadline_at": 1012000, "deadline_at": 1012000,
"expired": False, "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": { "restored": {
"identity": "issue:r:42:", "identity": "issue:r:42:",
"deadline_at": 1012000, "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(): def test_break_expiry_never_restarts_time_and_resume_is_explicit_and_idempotent():
script = TIMER.read_text() + r""" script = TIMER.read_text() + r"""
const values = new Map(); const values = new Map();

View File

@ -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(): def test_previous_owner_pauses_after_another_device_claims():
result = run_node( result = run_node(
r""" r"""

View File

@ -22,6 +22,7 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
"identity": "issue:stackchain/dashboard:42:", "identity": "issue:stackchain/dashboard:42:",
"elapsed_ms": 90_000, "elapsed_ms": 90_000,
"running": True, "running": True,
"break_deadline_at": None,
"updated_at": 1234.5, "updated_at": 1234.5,
} }
assert TodayStore(path).get_session("timmy") == saved assert TodayStore(path).get_session("timmy") == saved
@ -31,10 +32,26 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
"identity": "", "identity": "",
"elapsed_ms": 0, "elapsed_ms": 0,
"running": False, "running": False,
"break_deadline_at": None,
"updated_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): def test_active_session_claim_rejects_a_stale_revision(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3") store = TodayStore(tmp_path / "today.sqlite3")
first = store.update_session( first = store.update_session(
@ -56,6 +73,33 @@ def test_active_session_claim_rejects_a_stale_revision(tmp_path):
assert store.get_session("timmy") == claimed 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
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 @pytest.mark.anyio
async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path): async def test_active_session_api_returns_current_state_on_claim_conflict(monkeypatch, tmp_path):
async def user(): async def user():