From fa47ded3abd259e281ba1300760915bcff0138fd Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 20:34:10 +0000 Subject: [PATCH 1/2] feat: sync first-task completion across devices (Closes #1148) --- frontend/mobile-first-task.js | 16 ++++++++--- frontend/today-sync.js | 32 ++++++++++++++++++---- src/main.py | 10 ++++++- src/today_store.py | 24 +++++++++++++++-- tests/test_mobile_first_task.py | 29 ++++++++++++++++++++ tests/test_today_store.py | 48 +++++++++++++++++++++++++++++++++ tests/test_today_sync.py | 37 +++++++++++++++++++++++++ 7 files changed, 184 insertions(+), 12 deletions(-) diff --git a/frontend/mobile-first-task.js b/frontend/mobile-first-task.js index 744b27c..215db54 100644 --- a/frontend/mobile-first-task.js +++ b/frontend/mobile-first-task.js @@ -44,9 +44,16 @@ function store(value) { const currentKey = key(); - if (!currentKey) return; - try { options.storage.setItem(currentKey, value); } - catch (_error) {} + const current = state(); + if (!currentKey || current === value || current === 'complete') return false; + try { + options.storage.setItem(currentKey, value); + return true; + } catch (_error) { return false; } + } + + function adoptRemote(value) { + return ['coaching', 'complete'].includes(value) && store(value); } function required() { @@ -95,6 +102,7 @@ function completeOutcome() { if (state() !== 'coaching') return false; store('complete'); + options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete')); renderCoach(); if (options.receipt) { options.receipt.hidden = false; @@ -120,5 +128,5 @@ options.eventTarget?.addEventListener('offline', render); } - return {required, open, refresh, render, start, completeOutcome}; + return {required, open, refresh, render, start, completeOutcome, adoptRemote}; }); diff --git a/frontend/today-sync.js b/frontend/today-sync.js index a6bf255..a32ae4a 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -61,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl revision: plan.revision, ids: plan.ids, capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, }; + if (['coaching', 'complete'].includes(plan.first_task_state)) { + snapshot.first_task_state = plan.first_task_state; + } if (plan.plan_date) { snapshot.plan_date = plan.plan_date; snapshot.timezone = plan.timezone || null; } try { storage?.setItem(snapshotKey(), JSON.stringify(snapshot)); + const login = String(getLogin?.() || '').trim().toLowerCase(); + const activationKey = login && 'stackchain.first-task.v1:' + login; + const current = activationKey && storage?.getItem(activationKey); + const rank = {'': 0, coaching: 1, complete: 2}; + if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) { + storage?.setItem(activationKey, snapshot.first_task_state); + } } catch (_error) { // A storage quota failure must not prevent the current tab from using server truth. } @@ -123,7 +133,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl } const operation = record?.operation; const valid = operation && typeof operation.operation_id === 'string' && - ['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && + ['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at)); if (valid) records.push({ ...record, recordKey }); else { @@ -151,7 +161,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl ? record.operation.base_revision : Math.max(0, savedRevision()), })) .filter(operation => operation && typeof operation.operation_id === 'string' && - ['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string'); + ['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string'); } catch (_error) { return []; } @@ -176,7 +186,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); } - function enqueue(action, itemId, direction = null) { + function enqueue(action, itemId, direction = null, fields = {}) { const operations = pending(); if (action === 'remove' && operations.some(operation => operation.action === 'remove' && operation.item_id === itemId @@ -186,7 +196,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl } const operation = { operation_id: operationId(), action, item_id: itemId, direction, - base_revision: Math.max(0, savedRevision()), + base_revision: Math.max(0, savedRevision()), ...fields, }; const storageKey = key(); if (!storageKey || !storage) return false; @@ -223,6 +233,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl } } + function enqueueActivation(state) { + if (!['coaching', 'complete'].includes(state)) return false; + const rank = {coaching: 1, complete: 2}; + const queued = pending().filter(operation => operation.action === 'activate'); + if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true; + queued.forEach(operation => removeOperation(operation.operation_id)); + return enqueue('activate', 'first-task', null, {activation_state: state}); + } + function enqueueRollover(proposed) { if (!proposed || proposed.action !== 'rollover') return false; const operation = { @@ -323,6 +342,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl function startLifecycle({ window: windowObject, document: documentObject }) { windowObject?.addEventListener?.('online', flush); + windowObject?.addEventListener?.('stackchain:first-task-complete', () => { + if (enqueueActivation('complete')) flush(); + }); documentObject?.addEventListener?.('visibilitychange', () => documentObject.hidden ? false : flush() ); @@ -332,7 +354,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl if (change.queue === 'today' && pending().length) flush(); }); - return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle }; + return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync; diff --git a/src/main.py b/src/main.py index c246a25..c5838bd 100644 --- a/src/main.py +++ b/src/main.py @@ -553,7 +553,7 @@ class NotificationReadBatch(BaseModel): class TodayOperation(BaseModel): operation_id: str = Field(min_length=1, max_length=100) - action: Literal["add", "remove", "move", "configure", "rollover"] + action: Literal["add", "remove", "move", "configure", "rollover", "activate"] item_id: str = Field(min_length=1, max_length=500) direction: Literal["up", "down"] | None = None base_revision: int | None = Field(default=None, ge=0) @@ -562,6 +562,7 @@ class TodayOperation(BaseModel): plan_date: str | None = Field(default=None, min_length=10, max_length=10) timezone: str | None = Field(default=None, min_length=1, max_length=100) ids: list[str] = Field(default_factory=list, max_length=5) + activation_state: Literal["coaching", "complete"] | None = None @model_validator(mode="after") def validate_action_fields(self): @@ -580,6 +581,11 @@ class TodayOperation(BaseModel): raise ValueError("rollover IDs must be unique") elif self.plan_date is not None or self.timezone is not None or self.ids: raise ValueError("date, timezone, and IDs are only valid for rollover") + if self.action == "activate": + if self.item_id != "first-task" or self.activation_state is None: + raise ValueError("activate requires a first-task activation state") + elif self.activation_state is not None: + raise ValueError("activation state is only valid for activate") if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440 for item_id, minutes in self.estimates.items()): raise ValueError("estimates must use bounded item IDs and minutes") @@ -591,6 +597,8 @@ class TodayOperation(BaseModel): data.pop("plan_date", None) data.pop("timezone", None) data.pop("ids", None) + if self.action != "activate": + data.pop("activation_state", None) return data diff --git a/src/today_store.py b/src/today_store.py index 039bd61..5141fa9 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -180,6 +180,7 @@ class TodayStore: capacity_minutes = payload.get("capacity_minutes") plan_date = payload.get("plan_date") timezone = payload.get("timezone") + first_task_state = payload.get("first_task_state", "") else: legacy = True ids = json.loads(row[1]) @@ -187,6 +188,7 @@ class TodayStore: capacity_minutes = row[2] plan_date = row[4] if len(row) > 4 else None timezone = row[5] if len(row) > 5 else None + first_task_state = "" if not isinstance(ids, list) or not isinstance(estimates, dict): raise PrivateStateEncryptionError("private state could not be decrypted") snapshot = { @@ -198,6 +200,8 @@ class TodayStore: if plan_date: snapshot["plan_date"] = plan_date snapshot["timezone"] = timezone + if first_task_state in {"coaching", "complete"}: + snapshot["first_task_state"] = first_task_state return snapshot, legacy def _sealed_plan(self, login: str, snapshot: dict) -> str: @@ -207,6 +211,7 @@ class TodayStore: "estimates": snapshot["estimates"], "plan_date": snapshot.get("plan_date"), "timezone": snapshot.get("timezone"), + "first_task_state": snapshot.get("first_task_state", ""), }, binding=f"plan:{login}") def get(self, login: str) -> dict: @@ -598,6 +603,7 @@ class TodayStore: "ids": ids, "capacity_minutes": snapshot["capacity_minutes"], "estimates": estimates, "plan_date": snapshot.get("plan_date"), "timezone": snapshot.get("timezone"), + "first_task_state": snapshot.get("first_task_state", ""), }) if row is None: connection.execute( @@ -621,6 +627,8 @@ class TodayStore: if snapshot.get("plan_date"): result["plan_date"] = snapshot["plan_date"] result["timezone"] = snapshot["timezone"] + if snapshot.get("first_task_state"): + result["first_task_state"] = snapshot["first_task_state"] return result def apply_batch(self, login: str, operations: list[dict]) -> dict: @@ -638,6 +646,7 @@ class TodayStore: estimates = dict(snapshot["estimates"]) plan_date = snapshot.get("plan_date") timezone = snapshot.get("timezone") + first_task_state = snapshot.get("first_task_state", "") revision = snapshot["revision"] accepted: list[str] = [] duplicates: list[str] = [] @@ -650,7 +659,7 @@ class TodayStore: direction = operation.get("direction") if not operation_id or not item_id: raise ValueError("operation_id and item_id are required") - if action not in {"add", "remove", "move", "configure", "rollover"}: + if action not in {"add", "remove", "move", "configure", "rollover", "activate"}: raise ValueError("unsupported Today action") if action == "move" and direction not in {"up", "down"}: raise ValueError("move direction must be up or down") @@ -667,7 +676,7 @@ class TodayStore: base_revision = snapshot["revision"] if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0: raise ValueError("base_revision must be a non-negative integer") - if base_revision < snapshot["revision"]: + if action != "activate" and base_revision < snapshot["revision"]: self._record_operation(connection, login, operation_id) rejected.append({"operation_id": operation_id, "reason": "stale_intent"}) continue @@ -714,6 +723,14 @@ class TodayStore: changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates capacity_minutes = proposed_capacity estimates = normalized_estimates + elif action == "activate": + proposed_state = operation.get("activation_state") + if item_id != "first-task" or proposed_state not in {"coaching", "complete"}: + raise ValueError("first-task activation state is invalid") + rank = {"": 0, "coaching": 1, "complete": 2} + changed = rank[proposed_state] > rank[first_task_state] + if changed: + first_task_state = proposed_state else: proposed_date = operation.get("plan_date") proposed_timezone = operation.get("timezone") @@ -764,6 +781,7 @@ class TodayStore: sealed = self._sealed_plan(login, { "ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates, "plan_date": plan_date, "timezone": timezone, + "first_task_state": first_task_state, }) if row is None: connection.execute( @@ -789,4 +807,6 @@ class TodayStore: if plan_date: result["plan_date"] = plan_date result["timezone"] = timezone + if first_task_state: + result["first_task_state"] = first_task_state return result diff --git a/tests/test_mobile_first_task.py b/tests/test_mobile_first_task.py index 87f5e08..7d04d3d 100644 --- a/tests/test_mobile_first_task.py +++ b/tests/test_mobile_first_task.py @@ -118,6 +118,34 @@ process.stdout.write(JSON.stringify({ } +def test_first_task_adopts_remote_completion_without_crossing_accounts(): + result = run_node( + """ +const values = new Map(); let login='timmy'; let todayActive=false; +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +const controller=createFirstTask({ + storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true, + mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(), + findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(), + status:new Element(), +}); +const adopted=controller.adoptRemote('complete'); +const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')}; +login='alexander'; const alexanderRequired=controller.required(); +todayActive=true; controller.refresh(); controller.completeOutcome(); +process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired, + alexander:values.get('stackchain.first-task.v1:alexander')})); +""" + ) + + assert result == { + "adopted": True, + "timmy": {"required": False, "stored": "complete"}, + "alexanderRequired": True, + "alexander": "complete", + } + + def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline(): result = run_node( """ @@ -174,6 +202,7 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation(): assert '' in html assert "const mobileFirstTask = createMobileFirstTask({" in html assert "isTodayActive: () => workSession.checkpointed()" in html + assert "shouldActivate: () => mobileFirstTask.required()" in html assert "openActivation: () => mobileFirstTask.open()" in html assert "mobileFirstTask.refresh()" in html diff --git a/tests/test_today_store.py b/tests/test_today_store.py index 9acbfc4..b1ee816 100644 --- a/tests/test_today_store.py +++ b/tests/test_today_store.py @@ -304,6 +304,36 @@ def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_pa writer.close() +def test_first_task_completion_is_monotonic_durable_and_account_scoped(tmp_path): + path = tmp_path / "today.sqlite3" + key = b"f" * 32 + store = TodayStore(path, encryption_key=key) + store.apply("timmy", "plan-change", "add", "issue:r:1:") + + completed = store.apply_batch("Timmy", [{ + "operation_id": "first-task-complete", + "action": "activate", + "item_id": "first-task", + "activation_state": "complete", + "base_revision": 0, + }]) + replayed_coaching = store.apply_batch("timmy", [{ + "operation_id": "late-coaching", + "action": "activate", + "item_id": "first-task", + "activation_state": "coaching", + "base_revision": 0, + }]) + + assert completed["first_task_state"] == "complete" + assert replayed_coaching["first_task_state"] == "complete" + assert TodayStore(path, encryption_key=key).get("timmy")["first_task_state"] == "complete" + assert "first_task_state" not in store.get("alexander") + retained = path.read_bytes() + assert b'first_task_state' not in retained + assert b'complete' not in retained + + def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path): path = tmp_path / "today.sqlite3" store = TodayStore(path, limit=3) @@ -499,6 +529,24 @@ def test_today_api_model_accepts_bounded_capacity_configuration(): } +def test_today_api_model_accepts_only_bounded_first_task_activation(): + operation = main.TodayOperation( + operation_id="activation", action="activate", item_id="first-task", + activation_state="complete", base_revision=7, + ) + + assert operation.model_dump() == { + "operation_id": "activation", "action": "activate", "item_id": "first-task", + "direction": None, "base_revision": 7, "capacity_minutes": None, + "estimates": {}, "activation_state": "complete", + } + with pytest.raises(ValueError): + main.TodayOperation( + operation_id="bad", action="activate", item_id="another-flow", + activation_state="complete", + ) + + @pytest.mark.anyio async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch): async def user(): diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 464bd8a..aefea3e 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -188,6 +188,43 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); } +def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion(): + script = f""" +const createTodaySync = require({json.dumps(str(TODAY_SYNC))}); +const values = new Map(); let sequence=0; const adopted=[]; const delivered=[]; const listeners={{}}; +const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null, + getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +const sync = createTodaySync({{ + storage, getLogin:()=> 'timmy', createOperationId:()=> 'activation-' + (++sequence), + fetchJson:async(_url, options={{}})=>{{ + if (!options.method) return {{revision:4,ids:[],first_task_state:'complete'}}; + const operations=JSON.parse(options.body).operations; delivered.push(...operations); + return {{revision:5,ids:[],first_task_state:'complete',accepted_operation_ids:operations.map(x=>x.operation_id)}}; + }}, + onRemotePlan:plan=>adopted.push(plan.first_task_state), onRemoteIds:()=>{{}}, onStatus:()=>{{}}, +}}); +sync.startLifecycle({{window:{{addEventListener:(name,callback)=>listeners[name]=callback}},document:{{addEventListener(){{}}}}}}); +listeners['stackchain:first-task-complete'](); +listeners['stackchain:first-task-complete'](); +(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{ + delivered,adopted,pending:sync.pending(),hydrated:values.get('stackchain.first-task.v1:timmy') +}}));}})(); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + assert result == { + "delivered": [{ + "operation_id": "activation-1", "action": "activate", "item_id": "first-task", + "direction": None, "activation_state": "complete", "base_revision": 0, + }], + "adopted": ["complete"], + "pending": [], + "hydrated": "complete", + } + + def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() From de405efdbc0c77e958d6161831966cc9480da00d Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 20:44:03 +0000 Subject: [PATCH 2/2] test: verify first-task completion on a replacement phone --- .../e2e/test_mobile_home_bootstrap_release.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 9a910f5..6456642 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -84,7 +84,25 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work( assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True expect(page.locator("#mobile-first-task-receipt")).to_be_visible() expect(coach).to_be_hidden() + expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account") assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") + + fresh_context = browser.new_context( + viewport={"width": width, "height": height}, ignore_https_errors=True + ) + fresh_page = fresh_context.new_page() + fresh_page.goto(origin + "/", wait_until="networkidle") + fresh_page.locator('input[name="device_label"]').fill("Replacement release phone") + fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN) + fresh_page.locator("#submit-sign-in").click() + fresh_page.wait_for_url(origin + "/", wait_until="networkidle") + expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work") + assert fresh_page.evaluate( + "localStorage.getItem('stackchain.first-task.v1:timmy')" + ) == "complete" + fresh_page.locator('[data-mobile-task="work"]').click() + expect(fresh_page.locator("#mobile-first-task")).to_be_hidden() + fresh_context.close() browser.close() finally: fake.shutdown()