From c3cb498ce1058ccba78657f802a5e4d8e10084c7 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 20:24:14 +0000 Subject: [PATCH] feat: keep Following changes for later (Closes #1320) --- README.md | 4 +- frontend/dashboard.css | 1 + frontend/dashboard.js | 2 + frontend/following.js | 64 ++++++++++++++++++---- frontend/index.html | 2 + frontend/service-worker.js | 2 +- src/following_store.py | 57 +++++++++++++++++-- src/frontend_bundle.py | 4 +- src/main.py | 28 ++++++++++ tests/e2e/test_mobile_following_release.py | 9 +++ tests/test_comment_next.py | 2 +- tests/test_following_api.py | 29 ++++++++++ tests/test_following_frontend.py | 57 ++++++++++++++++++- tests/test_following_store.py | 30 ++++++++++ tests/test_frontend_bundle.py | 2 + tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_mobile_insights.py | 2 +- tests/test_mobile_start_day.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 32 +++++------ tests/test_today_readiness.py | 2 +- tests/test_today_sync.py | 2 +- 25 files changed, 296 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 42e11b6..a4c1957 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,9 @@ Following is a read-first, account-scoped collection: it is encrypted at rest, r pull-request Search Preview; confirmed **Stop watching** removes an open item. When watched work closes or merges, the sequential review exposes **Stop watching & next** so the completed item can be retired without leaving the preview; the next captured change opens immediately, and retiring the -final item completes the Following phase. Failed or unconfirmed Gitea mutations leave the collection +final item completes the Following phase. For open work that still needs thought, **Keep for later & next** +restores only the loaded revision to the unseen queue and continues the captured pass without changing +Gitea state, ownership, planning, or watch status. Failed or unconfirmed Gitea mutations leave the collection and current review position unchanged. Following counts never influence the recommended Work queue. Set `STACKCHAIN_FOLLOWING_DB` to override `.stackchain-state/following.sqlite3`. diff --git a/frontend/dashboard.css b/frontend/dashboard.css index b408967..8a97c70 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -1052,6 +1052,7 @@ textarea { resize: vertical; min-height: 120px; } .search-preview-reply .conversation-photo-actions { max-width:100%; } .search-preview-navigation { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:8px; } .search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; } +#keep-following-status { display:block; min-height:20px; overflow-wrap:anywhere; } .search-preview-actions button, .search-preview-actions a { min-height:44px; box-sizing:border-box; display:flex; align-items:center; justify-content:center; } .search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } @media (max-width:420px) { diff --git a/frontend/dashboard.js b/frontend/dashboard.js index 94ae1ed..a909a04 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -5661,6 +5661,7 @@ const renderSearchConversation = conversation => renderSearchPreviewConversation(conversation, document, escapeHtml, fmt, renderMarkdown); function renderSearchPreview(state) { + followingQueue.preview(state); const sheet = qs('#search-preview'); const status = qs('#search-preview-status'); const claimButton = qs('#claim-search-result'); @@ -6154,6 +6155,7 @@ closeSearchPreview(false); } }); + qs('#share-search-result').addEventListener('click', () => { searchPreview.share(canonicalSearchPreviewUrl()).catch(() => {}); }); diff --git a/frontend/following.js b/frontend/following.js index 0610398..17b9486 100644 --- a/frontend/following.js +++ b/frontend/following.js @@ -65,6 +65,23 @@ return true; } + async function keepForLater(item) { + const exact = candidate => sameItem(candidate, item) && candidate.updated_at === item?.updated_at; + const current = snapshot.items.find(exact); + const index = review?.active ? review.items.findIndex(exact) : -1; + if (!current || index < 0 || typeof options.onKeep !== 'function') return null; + await options.onKeep(current); + current.has_unseen_change = true; + review.acknowledged.delete(current.kind + ':' + current.repository + '#' + + current.number + '@' + current.updated_at); + review.items.splice(index, 1); + const next = review.items[index] || null; + if (!next) review.active = false; + publish('ready'); + if (next) await open(snapshot.items.findIndex(candidate => sameItem(candidate, next))); + return next; + } + async function open(index) { const item = snapshot.items[Number(index)]; if (!item) return false; @@ -117,7 +134,7 @@ } return { - load, open, startReview, previewLoaded:acknowledge, finishReview, retire, + load, open, startReview, previewLoaded:acknowledge, keepForLater, finishReview, retire, session:() => review?.active ? {items:[...review.items], more:false} : null, items:() => snapshot.items.map(item => ({...item})), count:() => snapshot.items.length, @@ -136,7 +153,17 @@ if (!response.ok) throw new Error(payload.detail || payload.error || 'Following is temporarily unavailable.'); return payload; }; + const changeRevision = (item, action) => { + const [owner, repo] = item.repository.split('/'); + return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + + encodeURIComponent(repo) + '/issues/' + item.number + '/' + action + + '?kind=' + encodeURIComponent(item.kind), { + method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'}, + body:JSON.stringify({updated_at:item.updated_at}), + }); + }; let feature; + let activeReviewItem = null; const show = () => query('#following-sheet').open || query('#following-sheet').showModal(); function render(state) { hooks.onStatus?.(state.status); @@ -176,17 +203,13 @@ (count === 1 ? ' unseen change' : ' unseen changes')); hooks.onCount?.(count, feature.items()); }, - onOpen, - onReviewComplete:hooks.onReviewComplete, - onAcknowledge:item => { - const [owner, repo] = item.repository.split('/'); - return fetchJson('api/v1/following/' + encodeURIComponent(owner) + '/' + - encodeURIComponent(repo) + '/issues/' + item.number + - '/seen?kind=' + encodeURIComponent(item.kind), { - method:'PUT', headers:{'Content-Type':'application/json', Accept:'application/json'}, - body:JSON.stringify({updated_at:item.updated_at}), - }); + onOpen:item => { + activeReviewItem = item; + return onOpen(item); }, + onReviewComplete:hooks.onReviewComplete, + onAcknowledge:item => changeRevision(item, 'seen'), + onKeep:item => changeRevision(item, 'keep'), }); query('#close-following').addEventListener('click', () => query('#following-sheet').close()); query('#review-following').addEventListener('click', () => { @@ -196,6 +219,19 @@ }); }); query('#retry-following').addEventListener('click', () => feature.load().catch(() => {})); + const keepButton = query('#keep-following-for-later'); + keepButton.addEventListener('click', async () => { + const status = query('#keep-following-status'); + keepButton.disabled = true; + status.textContent = 'Keeping this change for later…'; + try { + if (!await feature.keepForLater(activeReviewItem)) query('#close-search-preview').click(); + status.textContent = 'Kept for later.'; + } catch (_) { + status.textContent = 'Could not keep this change for later. Retry when ready.'; + keepButton.disabled = false; + } + }); return { load:feature.load, review:feature.startReview, @@ -209,7 +245,13 @@ }, session:feature.session, previewLoaded:feature.previewLoaded, + keepForLater:feature.keepForLater, retire:feature.retire, + preview(state) { + keepButton.hidden = state?.item?.following !== true; + keepButton.disabled = false; + if (keepButton.hidden || state.status === 'loading') query('#keep-following-status').textContent = ''; + }, returnToFollowing() { const completed = feature.finishReview(); if (completed) return 'completed-following'; diff --git a/frontend/index.html b/frontend/index.html index 390a4a5..3b63734 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -960,7 +960,9 @@ + + Open in Gitea diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 905f9c8..7a58186 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,7 +1,7 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/private-data-registry.js'); importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v135'; +const CACHE = 'stackchain-dashboard-shell-v136'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; diff --git a/src/following_store.py b/src/following_store.py index d82d515..b37efb5 100644 --- a/src/following_store.py +++ b/src/following_store.py @@ -97,7 +97,7 @@ class FollowingStore: or len(last_seen_updated_at) > 64 ): raise ValueError("last seen update is invalid") - return { + item = { "repository": repository, "kind": kind, "number": number, @@ -107,6 +107,12 @@ class FollowingStore: "url": url, "last_seen_updated_at": last_seen_updated_at, } + kept_updated_at = raw.get("kept_updated_at") + if kept_updated_at is not None: + if not isinstance(kept_updated_at, str) or not kept_updated_at or len(kept_updated_at) > 64: + raise ValueError("kept update is invalid") + item["kept_updated_at"] = kept_updated_at + return item @classmethod def _present(cls, snapshot: dict) -> dict: @@ -114,8 +120,10 @@ class FollowingStore: unchanged = [] for raw in snapshot["items"]: stored = cls._normalize_item(raw) - unseen = stored["updated_at"] != stored["last_seen_updated_at"] - item = {key: value for key, value in stored.items() if key != "last_seen_updated_at"} + unseen = (stored["updated_at"] != stored["last_seen_updated_at"] or + stored.get("kept_updated_at") == stored["updated_at"]) + item = {key: value for key, value in stored.items() + if key not in {"last_seen_updated_at", "kept_updated_at"}} item["has_unseen_change"] = unseen (changed if unseen else unchanged).append(item) changed.sort(key=lambda item: item["updated_at"], reverse=True) @@ -186,6 +194,8 @@ class FollowingStore: items.insert(0, item) else: item["last_seen_updated_at"] = items[index]["last_seen_updated_at"] + if "kept_updated_at" in items[index]: + item["kept_updated_at"] = items[index]["kept_updated_at"] if items[index] == item: return self._present({"revision": current["revision"], "items": items}) items.pop(index) @@ -219,6 +229,8 @@ class FollowingStore: if update is None: continue update["last_seen_updated_at"] = item["last_seen_updated_at"] + if "kept_updated_at" in item: + update["kept_updated_at"] = item["kept_updated_at"] if update != item: items[index] = update changed = True @@ -256,8 +268,45 @@ class FollowingStore: for item in items: if self._identity(item) != identity or item["updated_at"] != updated_at: continue - if item["last_seen_updated_at"] != updated_at: + if (item["last_seen_updated_at"] != updated_at or + item.get("kept_updated_at") == updated_at): item["last_seen_updated_at"] = updated_at + item.pop("kept_updated_at", None) + revision += 1 + connection.execute( + "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", + (revision, self._seal(login, items), login), + ) + break + return self._present({"revision": revision, "items": items}) + + def keep_unseen( + self, + login: str, + repository: str, + number: int, + updated_at: str, + *, + kind: str = "issue", + ) -> dict: + """Restore only the exact loaded revision to the unseen review queue.""" + login = self._login(login) + if kind not in _KINDS: + raise ValueError("kind is invalid") + identity = (kind, str(repository).lower(), number) + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT revision, items FROM following_issues WHERE login = ?", (login,) + ).fetchone() + current, _legacy = self._snapshot(row, login) + items = [self._normalize_item(candidate) for candidate in current["items"]] + revision = current["revision"] + for item in items: + if self._identity(item) != identity or item["updated_at"] != updated_at: + continue + if item.get("kept_updated_at") != updated_at: + item["kept_updated_at"] = updated_at revision += 1 connection.execute( "UPDATE following_issues SET revision = ?, items = ? WHERE login = ?", diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 54483db..c0d6490 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -35,11 +35,11 @@ FEATURE_SOURCES = { "security-center": ("static/security-center.js",), "planning": ( "static/plan-today.js", "static/plan-today-readiness.js", - "static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", + "static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js", "static/following.js", "static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js", ), "today-timer": ( - "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/following.js", "static/mobile-composer-viewport.js", + "static/mobile-app-badge.js", "static/conversation.js", "static/widgets.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.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/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.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-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js", "static/later-work.js", "static/detail-defer.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", diff --git a/src/main.py b/src/main.py index 12409d1..6182782 100644 --- a/src/main.py +++ b/src/main.py @@ -2725,6 +2725,34 @@ async def acknowledge_following_revision( ) +@app.put("/api/v1/following/{owner}/{repo}/issues/{number}/keep") +async def keep_following_revision( + payload: FollowingSeenRevision, + owner: str, + repo: str, + number: int = PathParam(gt=0), + kind: Literal["issue", "pull"] = Query(default="issue"), +): + login = await _confirmed_login() + try: + return await asyncio.to_thread( + _following_store().keep_unseen, + login, + f"{owner}/{repo}", + number, + payload.updated_at, + kind=kind, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except (OSError, sqlite3.Error, PrivateStateEncryptionError): + raise HTTPException( + status_code=503, + detail="Following synchronization is unavailable", + headers={"Retry-After": "1"}, + ) + + @app.get("/api/v1/completed-filed-reviews") async def get_completed_filed_reviews(response: Response): login = await _confirmed_login() diff --git a/tests/e2e/test_mobile_following_release.py b/tests/e2e/test_mobile_following_release.py index 624f9a6..d92f8c4 100644 --- a/tests/e2e/test_mobile_following_release.py +++ b/tests/e2e/test_mobile_following_release.py @@ -77,6 +77,15 @@ def test_following_queue_is_phone_usable_at_narrow_viewport(viewport): {status:'ready'}, document.querySelector('#watch-search-result'))""") expect(retire).to_have_text("Watch pull request") assert retire.bounding_box()["height"] >= 44 + keep = page.locator("#keep-following-for-later") + page.evaluate("document.querySelector('#keep-following-for-later').hidden = false") + expect(keep).to_be_visible() + expect(keep).to_have_text("Keep for later & next") + assert keep.bounding_box()["height"] >= 44 + assert page.locator("#keep-following-status").evaluate( + "node => getComputedStyle(node).overflowWrap" + ) == "anywhere" + assert page.locator("#keep-following-status").bounding_box()["height"] >= 20 overflow = page.evaluate("document.documentElement.scrollWidth > document.documentElement.clientWidth") assert overflow is False browser.close() diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index ddb7f28..5ed80be 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v135" in worker + assert "stackchain-dashboard-shell-v136" in worker diff --git a/tests/test_following_api.py b/tests/test_following_api.py index 4b2081b..4119565 100644 --- a/tests/test_following_api.py +++ b/tests/test_following_api.py @@ -293,6 +293,35 @@ async def test_following_acknowledges_only_the_exact_loaded_revision(monkeypatch assert response.json()["items"][0]["has_unseen_change"] is False +@pytest.mark.anyio +async def test_following_keep_restores_only_the_loaded_revision(monkeypatch, tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"f" * 32) + item = { + "repository": "stackchain/api", "kind": "pull", "number": 42, "title": "Changed", + "state": "open", "updated_at": "2026-08-23T03:00:00Z", + "url": "https://forge.example/stackchain/api/pulls/42", + } + store.set_watching("timmy", item, True) + changed = {**item, "updated_at": "2026-08-23T04:00:00Z"} + store.refresh("timmy", [changed]) + store.acknowledge("timmy", item["repository"], item["number"], changed["updated_at"], kind="pull") + + async def user(): + return {"login": "timmy"} + + monkeypatch.setattr(main, "_following_store", lambda: store) + monkeypatch.setattr(main, "current_user", user) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.put( + "/api/v1/following/stackchain/api/issues/42/keep?kind=pull", + json={"updated_at": changed["updated_at"]}, + ) + + assert response.status_code == 200 + assert response.json()["items"][0]["has_unseen_change"] is True + + @pytest.mark.anyio async def test_closed_following_issue_can_be_unwatched_but_not_newly_watched(monkeypatch, tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=b"g" * 32) diff --git a/tests/test_following_frontend.py b/tests/test_following_frontend.py index 4aea579..ecf1644 100644 --- a/tests/test_following_frontend.py +++ b/tests/test_following_frontend.py @@ -96,6 +96,23 @@ def test_dashboard_dispatches_following_route_to_changed_first_review(): assert route_handler.index("followingQueue.route") < route_handler.index("selectWorkQueue") +def test_following_preview_wires_keep_for_later_action_and_retryable_failure(): + html = (ROOT / "frontend" / "index.html").read_text() + dashboard = (ROOT / "frontend" / "dashboard.js").read_text() + following = MODULE.read_text() + + assert 'id="keep-following-for-later"' in html + assert 'id="keep-following-status"' in html + assert "followingQueue.preview(state)" in dashboard + assert "query('#close-search-preview').click()" in following + assert "state?.item?.following !== true" in following + assert "feature.keepForLater(activeReviewItem)" in following + assert "Kept for later" in following + assert "Could not keep this change for later" in following + assert "changeRevision(item, 'keep')" in following + assert "keepForLater:feature.keepForLater" in following + + def test_following_typed_identity_prevents_issue_pull_collisions(): script = f""" const createFollowing = require({json.dumps(str(MODULE))}); @@ -243,6 +260,42 @@ const feature = createFollowing({{ assert result["renders"][-1]["reviewSummary"] == {"reviewed": 2, "remaining": 0} +def test_following_keep_for_later_preserves_revision_and_advances_without_looping(): + script = f""" +const createFollowing = require({json.dumps(str(MODULE))}); +const state = {{opened:[], acknowledged:[], kept:[], counts:[]}}; +const items=[ + {{repository:'stackchain/api',number:42,title:'First',updated_at:'2026-08-23T06:00:00Z',has_unseen_change:true}}, + {{repository:'stackchain/web',number:9,title:'Second',updated_at:'2026-08-23T05:00:00Z',has_unseen_change:true}} +]; +const feature = createFollowing({{ + fetchJson:async () => ({{revision:7,items}}), + onOpen:async item => state.opened.push(item.number), + onAcknowledge:async item => state.acknowledged.push(item.number), + onKeep:async item => state.kept.push(item.number), + onCount:value => state.counts.push(value), +}}); +(async () => {{ + await feature.load(); + await feature.startReview(); + const kept = await feature.keepForLater(feature.session().items[0]); + process.stdout.write(JSON.stringify({{ + kept, session:feature.session(), items:feature.items(), state + }})); +}})().catch(error => {{ console.error(error); process.exit(1); }}); +""" + result = json.loads(subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ).stdout) + + assert result["kept"]["number"] == 9 + assert [item["number"] for item in result["session"]["items"]] == [9] + assert result["items"][0]["has_unseen_change"] is True + assert result["state"] == { + "opened": [42, 9], "acknowledged": [42, 9], "kept": [42], "counts": [2, 1, 2, 1] + } + + def test_following_review_notifies_prepare_today_when_final_revision_is_loaded(): script = f""" const createFollowing = require({json.dumps(str(MODULE))}); @@ -367,13 +420,13 @@ def test_following_review_controls_are_wired_into_the_phone_preview_flow(): assert "query('#review-following').addEventListener('click'" in following assert "getSession:() => followingQueue.session() || commandSearchState" in dashboard assert "onOpened:item => followingQueue.previewLoaded(item)" in dashboard - assert "'/seen?kind=' + encodeURIComponent(item.kind)" in following + assert "changeRevision(item, 'seen')" in following assert "afterUnwatch:item => followingQueue.retire(item)" in dashboard assert "followingQueue.returnToFollowing()" in dashboard assert "'Back to Following'" in dashboard assert "if (searchPreviewReturnKind === 'following')" in dashboard assert "e.key === 'Escape' && searchPreviewReturnKind === 'following'" in dashboard - assert "stackchain-dashboard-shell-v135" in service_worker + assert "stackchain-dashboard-shell-v136" in service_worker def test_prepare_today_lazily_refreshes_and_directly_reviews_following(): diff --git a/tests/test_following_store.py b/tests/test_following_store.py index ca74a79..8dfd44b 100644 --- a/tests/test_following_store.py +++ b/tests/test_following_store.py @@ -66,6 +66,36 @@ def test_acknowledgement_is_revision_conditional_and_later_change_is_unseen(tmp_ assert store.get("timmy")["items"][0]["has_unseen_change"] is True +def test_keep_for_later_restores_the_exact_seen_revision_as_unseen(tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) + store.set_watching("timmy", ITEM, True) + changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"} + store.refresh("timmy", [changed]) + store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]) + + snapshot = store.keep_unseen( + "timmy", ITEM["repository"], ITEM["number"], changed["updated_at"] + ) + + assert snapshot["items"][0]["has_unseen_change"] is True + assert store.get("timmy")["items"][0]["has_unseen_change"] is True + + +def test_reopening_a_kept_revision_acknowledges_it_again(tmp_path): + store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) + store.set_watching("timmy", ITEM, True) + changed = {**ITEM, "updated_at": "2026-08-23T04:00:00Z"} + store.refresh("timmy", [changed]) + store.acknowledge("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]) + store.keep_unseen("timmy", ITEM["repository"], ITEM["number"], changed["updated_at"]) + + snapshot = store.acknowledge( + "timmy", ITEM["repository"], ITEM["number"], changed["updated_at"] + ) + + assert snapshot["items"][0]["has_unseen_change"] is False + + def test_reconfirmed_watch_does_not_mark_an_unseen_change_as_seen(tmp_path): store = FollowingStore(tmp_path / "following.sqlite3", encryption_key=KEY) store.set_watching("timmy", ITEM, True) diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 22d27ac..6769456 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -72,6 +72,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): assert b"function createQueueToday" not in first.runtime_bytes assert b"function createQueueToday" in first.feature_bundles["today-timer"].runtime_bytes assert b"function createPlanToday" in first.feature_bundles["planning"].runtime_bytes + assert b"function createFollowing" in first.feature_bundles["planning"].runtime_bytes + assert b"function createFollowing" not in first.feature_bundles["today-timer"].runtime_bytes assert b"function createTomorrowPlan" in first.feature_bundles["planning"].runtime_bytes assert b"function createPlanToday" not in first.runtime_bytes assert b"function createConversationPager" in first.feature_bundles["today-timer"].runtime_bytes diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 0ba47a5..471d050 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -435,5 +435,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index 62fc7a5..d8c844d 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -256,4 +256,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers(): assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v135" in worker + assert "stackchain-dashboard-shell-v136" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index 29d72d6..ce503bd 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0])) assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}" - assert "stackchain-dashboard-shell-v135" in worker + assert "stackchain-dashboard-shell-v136" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index a7acf74..8143398 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -383,7 +383,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "controller.recoverPermission('deadline')" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v135" in worker + assert "stackchain-dashboard-shell-v136" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_mobile_insights.py b/tests/test_mobile_insights.py index 4c3b667..dee74a4 100644 --- a/tests/test_mobile_insights.py +++ b/tests/test_mobile_insights.py @@ -243,5 +243,5 @@ async def test_mobile_home_progressively_discloses_secondary_panels_as_insights( def test_mobile_insights_rolls_into_the_offline_shell(): worker = (CONTROLLER.parent / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v135" in worker + assert "stackchain-dashboard-shell-v136" in worker assert "BASE + 'static/mobile-insights.js'" in worker diff --git a/tests/test_mobile_start_day.py b/tests/test_mobile_start_day.py index c7945fb..b63e2de 100644 --- a/tests/test_mobile_start_day.py +++ b/tests/test_mobile_start_day.py @@ -414,7 +414,7 @@ async def test_dashboard_wires_thumb_safe_start_day_briefing_into_offline_mobile assert ".mobile-start-day-finish { min-height:44px;" in html assert "max-width:100%; overflow-wrap:anywhere;" in html assert "BASE + 'static/mobile-start-day.js'" in service_worker - assert "stackchain-dashboard-shell-v135" in service_worker + assert "stackchain-dashboard-shell-v136" in service_worker @pytest.mark.anyio diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 77c02a6..d99e354 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -418,7 +418,7 @@ async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner(): def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-readiness.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 4f589fc..e0e41c8 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -186,7 +186,7 @@ async function dispatchPush(payload) {{ def test_week_unplan_undo_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/week-plan.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -194,20 +194,20 @@ def test_week_unplan_undo_rolls_the_offline_shell(): def test_private_today_action_mailbox_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source def test_per_day_week_conflict_ui_rolls_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/week-plan.js'" in source def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -216,7 +216,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/authored-outbox.js'" in source assert "BASE + 'static/background-issue-sync.js'" in source @@ -225,7 +225,7 @@ def test_mobile_conversation_photo_bundles_roll_the_offline_shell(): def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/issue-evidence-review.js'" in source assert "BASE + 'static/issue-attachment.js'" in source @@ -233,14 +233,14 @@ def test_photo_metadata_sanitizer_rolls_the_cached_optimizer_atomically(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -248,7 +248,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -256,7 +256,7 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/issue-sheet.js'" in source assert "BASE + 'static/checklist-conflict.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -266,14 +266,14 @@ def test_inline_checklist_step_flow_rolls_the_offline_shell_atomically(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -282,21 +282,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/update-ownership.js'" in source @@ -1302,7 +1302,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/queue-today.js'" in source diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 88b36b4..b43e3dc 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v135';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v136';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 6cb6680..c4698e0 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -343,7 +343,7 @@ listeners['stackchain:first-task-complete'](); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v135" in source + assert "stackchain-dashboard-shell-v136" in source assert "BASE + 'static/today-sync.js'" in source