feat: continue timed breaks across devices (Closes #1038)
Some checks failed
CI / lint (pull_request) Successful in 2m53s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Failing after 2m8s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-17 20:08:05 +00:00
parent 1d242bab75
commit c5b466f281
7 changed files with 172 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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():