feat: hand off active Today sessions across devices (Closes #1022)
This commit is contained in:
parent
51f4223f4b
commit
8d12a0384b
|
|
@ -926,6 +926,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
|
|
||||||
.mobile-today-hud { display:none; }
|
.mobile-today-hud { display:none; }
|
||||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||||
|
.today-session-handoff { position:fixed; left:12px; right:12px; bottom:calc(68px + env(safe-area-inset-bottom)); z-index:45; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:12px; border:1px solid #60a5fa; border-radius:12px; background:rgba(16,38,65,.98); box-shadow:0 8px 28px rgba(0,0,0,.35); }
|
||||||
|
.today-session-handoff[hidden] { display:none; }
|
||||||
|
.today-session-handoff button { min-height:44px; }
|
||||||
|
@media(max-width:420px) { .today-session-handoff { grid-template-columns:1fr; } .today-session-handoff button { width:100%; } }
|
||||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||||
.mobile-task-action[hidden] { display:none; }
|
.mobile-task-action[hidden] { display:none; }
|
||||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||||
|
|
|
||||||
|
|
@ -1795,9 +1795,11 @@
|
||||||
'Agenda progress could not be saved on this device. You can keep working.';
|
'Agenda progress could not be saved on this device. You can keep working.';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let todaySessionSync = null;
|
||||||
const timer = createTodayTimer({
|
const timer = createTodayTimer({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => confirmedOwnerLogin,
|
getLogin: () => confirmedOwnerLogin,
|
||||||
|
onChange: snapshot => todaySessionSync?.publish(snapshot),
|
||||||
});
|
});
|
||||||
const timerView = createTodayTimerView({
|
const timerView = createTodayTimerView({
|
||||||
timer,
|
timer,
|
||||||
|
|
@ -1814,6 +1816,15 @@
|
||||||
return completeTodayItem(item);
|
return completeTodayItem(item);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
todaySessionSync = attachTodaySessionHandoff({
|
||||||
|
fetchJson:fetchReviewJson, storage:localStorage, timer, qs,
|
||||||
|
items:() => [...todayMyWork, ...activeMyWork],
|
||||||
|
identity:item => todayWork.identity(item),
|
||||||
|
selectToday:() => selectTodayWork(),
|
||||||
|
startItem:item => workSession.start(item),
|
||||||
|
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||||||
|
renderTimer:() => timerView.render(),
|
||||||
|
});
|
||||||
renderAttentionInterruption();
|
renderAttentionInterruption();
|
||||||
qs('#return-to-today').addEventListener('click', () => {
|
qs('#return-to-today').addEventListener('click', () => {
|
||||||
const returned = timer.returnFromAttention();
|
const returned = timer.returnFromAttention();
|
||||||
|
|
|
||||||
|
|
@ -1515,6 +1515,14 @@
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<section class="today-session-handoff" id="today-session-handoff" aria-live="polite" hidden>
|
||||||
|
<div>
|
||||||
|
<strong>Today is active on another device</strong>
|
||||||
|
<div class="small" id="today-session-handoff-summary"></div>
|
||||||
|
</div>
|
||||||
|
<button id="continue-today-session" type="button">Continue here</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||||
|
|
@ -1616,6 +1624,7 @@
|
||||||
<script src="static/work-selection.js"></script>
|
<script src="static/work-selection.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.js"></script>
|
||||||
<script src="static/today-timer.js"></script>
|
<script src="static/today-timer.js"></script>
|
||||||
|
<script src="static/today-session-sync.js"></script>
|
||||||
<script src="static/today-recap.js"></script>
|
<script src="static/today-recap.js"></script>
|
||||||
<script src="static/today-wrap-up.js"></script>
|
<script src="static/today-wrap-up.js"></script>
|
||||||
<script src="static/today-handoff.js"></script>
|
<script src="static/today-handoff.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const SHELL = [
|
||||||
BASE + 'static/work-selection.js',
|
BASE + 'static/work-selection.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.js',
|
||||||
BASE + 'static/today-timer.js',
|
BASE + 'static/today-timer.js',
|
||||||
|
BASE + 'static/today-session-sync.js',
|
||||||
BASE + 'static/today-recap.js',
|
BASE + 'static/today-recap.js',
|
||||||
BASE + 'static/today-wrap-up.js',
|
BASE + 'static/today-wrap-up.js',
|
||||||
BASE + 'static/today-handoff.js',
|
BASE + 'static/today-handoff.js',
|
||||||
|
|
|
||||||
149
frontend/today-session-sync.js
Normal file
149
frontend/today-session-sync.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
function createTodaySessionSync({
|
||||||
|
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||||
|
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||||
|
}) {
|
||||||
|
let current = null;
|
||||||
|
let ownedRevision = 0;
|
||||||
|
let pollTimer = null;
|
||||||
|
const endpoint = 'api/v1/today/session';
|
||||||
|
const deviceId = () => String(getDeviceId?.() || '').trim();
|
||||||
|
|
||||||
|
function adopt(session) {
|
||||||
|
if (!session || !Number.isInteger(session.revision)) return null;
|
||||||
|
const previousOwned = current?.device_id === deviceId() && current?.running;
|
||||||
|
current = session;
|
||||||
|
if (session.device_id === deviceId()) {
|
||||||
|
ownedRevision = session.revision;
|
||||||
|
onRemote(null);
|
||||||
|
} else if (session.running && session.identity) {
|
||||||
|
if (previousOwned) {
|
||||||
|
timer?.pause?.();
|
||||||
|
onTransferred(session);
|
||||||
|
}
|
||||||
|
onRemote(session);
|
||||||
|
} else {
|
||||||
|
onRemote(null);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const session = await fetchJson(endpoint);
|
||||||
|
onStatus('online');
|
||||||
|
return adopt(session);
|
||||||
|
} catch (error) {
|
||||||
|
onStatus('offline', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claim() {
|
||||||
|
if (!current?.running || !current.identity || current.device_id === deviceId() || !deviceId()) return null;
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
adopt(session);
|
||||||
|
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
onStatus('conflict', error);
|
||||||
|
await refresh();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(snapshot = timer?.snapshot?.()) {
|
||||||
|
if (!snapshot?.identity || !deviceId()) return null;
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
adopt(session);
|
||||||
|
return session;
|
||||||
|
} catch (error) {
|
||||||
|
onStatus('offline', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pulse = () => current?.device_id === deviceId() && current?.running
|
||||||
|
? publish()
|
||||||
|
: refresh();
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh, claim, publish,
|
||||||
|
session:() => current,
|
||||||
|
start(intervalMs = 15000) {
|
||||||
|
if (pollTimer !== null) return false;
|
||||||
|
pulse();
|
||||||
|
pollTimer = setInterval?.(pulse, intervalMs);
|
||||||
|
pollTimer?.unref?.();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (pollTimer === null) return false;
|
||||||
|
clearInterval?.(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTodaySessionHandoff({
|
||||||
|
fetchJson, storage, timer, qs, items, identity, selectToday, startItem, announce, renderTimer,
|
||||||
|
}) {
|
||||||
|
const deviceKey = 'stackchain.today-session-device.v1';
|
||||||
|
const getDeviceId = () => {
|
||||||
|
let value = storage.getItem(deviceKey);
|
||||||
|
if (!value) {
|
||||||
|
value = globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2);
|
||||||
|
storage.setItem(deviceKey, value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
let offered = null;
|
||||||
|
const sync = createTodaySessionSync({
|
||||||
|
fetchJson, getDeviceId, timer,
|
||||||
|
onRemote:session => {
|
||||||
|
offered = session;
|
||||||
|
const handoff = qs('#today-session-handoff');
|
||||||
|
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`;
|
||||||
|
},
|
||||||
|
onTransferred:() => {
|
||||||
|
announce('Today continued on another device. Timer paused here.');
|
||||||
|
renderTimer();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
qs('#continue-today-session').addEventListener('click', async () => {
|
||||||
|
const target = offered;
|
||||||
|
const claimed = await sync.claim();
|
||||||
|
if (!claimed || !target) return;
|
||||||
|
selectToday();
|
||||||
|
const item = items().find(entry => identity(entry) === claimed.identity);
|
||||||
|
if (item) startItem(item);
|
||||||
|
else announce('Today session moved here; refresh work to open its item.');
|
||||||
|
renderTimer();
|
||||||
|
});
|
||||||
|
sync.start(5000);
|
||||||
|
return sync;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = {
|
||||||
|
createTodaySessionSync, attachTodaySessionHandoff,
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange = () => {} }) {
|
||||||
const key = () => {
|
const key = () => {
|
||||||
const login = String(getLogin?.() || '').trim().toLowerCase();
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
|
||||||
|
|
@ -24,6 +24,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
if (!ownerKey || !storage) return false;
|
if (!ownerKey || !storage) return false;
|
||||||
try {
|
try {
|
||||||
storage.setItem(ownerKey, JSON.stringify(state));
|
storage.setItem(ownerKey, JSON.stringify(state));
|
||||||
|
onChange(snapshot());
|
||||||
return true;
|
return true;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -60,6 +61,22 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||||
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
|
adopt(identity, elapsedMs, running) {
|
||||||
|
if (!key() || typeof identity !== 'string' || !identity ||
|
||||||
|
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||||
|
const state = read();
|
||||||
|
settle(state);
|
||||||
|
state.active_identity = identity;
|
||||||
|
state.entries[identity] = {
|
||||||
|
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||||
|
started_at:running ? now() : null,
|
||||||
|
running:Boolean(running),
|
||||||
|
};
|
||||||
|
state.away_at = null;
|
||||||
|
state.pending_interruption = null;
|
||||||
|
state.attention_interruption = null;
|
||||||
|
return write(state);
|
||||||
|
},
|
||||||
activate(identity) {
|
activate(identity) {
|
||||||
if (!key() || typeof identity !== 'string' || !identity) return false;
|
if (!key() || typeof identity !== 'string' || !identity) return false;
|
||||||
const state = read();
|
const state = read();
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ FEATURE_SOURCES = {
|
||||||
"security-center": ("static/security-center.js",),
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
|
|
|
||||||
43
src/main.py
43
src/main.py
|
|
@ -64,7 +64,7 @@ from src.unfiled_draft_store import UnfiledDraftConflict, UnfiledDraftStore
|
||||||
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.later_store import LaterStore
|
from src.later_store import LaterStore
|
||||||
from src.today_store import TodayPlanFull, TodayStore
|
from src.today_store import TodayPlanFull, TodaySessionConflict, TodayStore
|
||||||
from src.views import FRONTEND_BUILD, router as frontend_router
|
from src.views import FRONTEND_BUILD, router as frontend_router
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -588,6 +588,14 @@ class TodayRecap(BaseModel):
|
||||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
|
class TodaySessionUpdate(BaseModel):
|
||||||
|
base_revision: int = Field(ge=0)
|
||||||
|
device_id: str = Field(min_length=1, max_length=100)
|
||||||
|
identity: str = Field(max_length=500)
|
||||||
|
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||||
|
running: bool
|
||||||
|
|
||||||
|
|
||||||
class TodayRecapTimeLog(TodayRecap):
|
class TodayRecapTimeLog(TodayRecap):
|
||||||
log_identities: list[str] = Field(min_length=1, max_length=20)
|
log_identities: list[str] = Field(min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
@ -1322,7 +1330,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -2456,6 +2464,37 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/today/session")
|
||||||
|
async def get_today_session():
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_today_store().get_session, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Today session synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/today/session")
|
||||||
|
async def update_today_session(payload: TodaySessionUpdate):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_today_store().update_session, login, **payload.model_dump()
|
||||||
|
)
|
||||||
|
except TodaySessionConflict as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={"code": "session_changed", "session": error.session},
|
||||||
|
)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Today session synchronization is unavailable",
|
||||||
|
headers={"Retry-After": "1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/today/recaps")
|
@app.get("/api/v1/today/recaps")
|
||||||
async def get_today_recaps(response: Response):
|
async def get_today_recaps(response: Response):
|
||||||
login = await _confirmed_login()
|
login = await _confirmed_login()
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,14 @@ class TodayPlanFull(ValueError):
|
||||||
"""Raised when an add would exceed the bounded Today plan."""
|
"""Raised when an add would exceed the bounded Today plan."""
|
||||||
|
|
||||||
|
|
||||||
|
class TodaySessionConflict(ValueError):
|
||||||
|
"""Raised when a device updates an obsolete active-session revision."""
|
||||||
|
|
||||||
|
def __init__(self, session: dict):
|
||||||
|
super().__init__("active Today session changed on another device")
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
|
||||||
class TodayStore:
|
class TodayStore:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -36,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] >= 1:
|
if connection.execute("PRAGMA user_version").fetchone()[0] >= 2:
|
||||||
connection.close()
|
connection.close()
|
||||||
return
|
return
|
||||||
connection.execute("PRAGMA journal_mode=WAL")
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
|
@ -106,7 +114,20 @@ class TodayStore:
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
connection.execute("PRAGMA user_version = 1")
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS today_sessions (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
identity TEXT NOT NULL,
|
||||||
|
elapsed_ms INTEGER NOT NULL,
|
||||||
|
running INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute("PRAGMA user_version = 2")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
@ -163,6 +184,57 @@ class TodayStore:
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return self._snapshot(row)
|
return self._snapshot(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _empty_session() -> dict:
|
||||||
|
return {
|
||||||
|
"revision": 0, "device_id": "", "identity": "",
|
||||||
|
"elapsed_ms": 0, "running": False, "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 "
|
||||||
|
"FROM today_sessions WHERE login = ?",
|
||||||
|
(self._normalize_login(login),),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
|
def update_session(
|
||||||
|
self, login: str, *, base_revision: int, device_id: str,
|
||||||
|
identity: str, elapsed_ms: int, running: bool,
|
||||||
|
) -> 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 "
|
||||||
|
"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],
|
||||||
|
}
|
||||||
|
raise TodaySessionConflict(session)
|
||||||
|
revision = current_revision + 1
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO today_sessions(login, revision, device_id, identity, elapsed_ms, running, 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),
|
||||||
|
)
|
||||||
|
return self.get_session(login)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
def _normalize_recap_items(items: list[dict]) -> list[dict]:
|
||||||
if not isinstance(items, list) or not items:
|
if not isinstance(items, list) or not items:
|
||||||
|
|
|
||||||
|
|
@ -988,6 +988,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/work-selection.js",
|
"/dashboard/static/work-selection.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.js",
|
||||||
"/dashboard/static/today-timer.js",
|
"/dashboard/static/today-timer.js",
|
||||||
|
"/dashboard/static/today-session-sync.js",
|
||||||
"/dashboard/static/today-recap.js",
|
"/dashboard/static/today-recap.js",
|
||||||
"/dashboard/static/today-wrap-up.js",
|
"/dashboard/static/today-wrap-up.js",
|
||||||
"/dashboard/static/today-handoff.js",
|
"/dashboard/static/today-handoff.js",
|
||||||
|
|
|
||||||
23
tests/test_today_session_handoff_ui.py
Normal file
23
tests/test_today_session_handoff_ui.py
Normal 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
tests/test_today_session_sync.py
Normal file
121
tests/test_today_session_sync.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,80 @@ import pytest
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from src import main
|
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
|
@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")
|
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 forbidden.status_code == 403
|
||||||
assert changed.status_code == 200
|
assert changed.status_code == 200
|
||||||
|
|
@ -361,3 +449,6 @@ async def test_authenticated_today_api_uses_confirmed_account_and_csrf(monkeypat
|
||||||
"estimates": {},
|
"estimates": {},
|
||||||
}
|
}
|
||||||
assert fetched.headers["cache-control"] == "no-store"
|
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
tests/test_today_timer_handoff.py
Normal file
51
tests/test_today_timer_handoff.py
Normal 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},
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue
Block a user