Continue a timed Today break across devices #1039
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user