From 9f6476a5b49b02428870c62eafe2f1059529bd7a Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 12:38:41 +0000 Subject: [PATCH] feat: capture cold-offline work for filing (#301) --- README.md | 7 ++- frontend/dashboard.css | 1 + frontend/dashboard.js | 63 ++++++++++++++++--- frontend/index.html | 2 + frontend/service-worker.js | 3 +- frontend/unfiled-captures.js | 65 +++++++++++++++++++ tests/test_service_worker.py | 3 +- tests/test_unfiled_captures.py | 111 +++++++++++++++++++++++++++++++++ 8 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 frontend/unfiled-captures.js create mode 100644 tests/test_unfiled_captures.py diff --git a/README.md b/README.md index 31a8780..6227e40 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ issue-comment API; mobile issue capture requires issue creation and assignment permission. The New issue sheet can optionally select an open repository milestone and due date; the dashboard validates both and sends them with self-assignment in the single create request, so planned work appears in its release -lane immediately. Issue capture and authored mobile actions (issue +lane immediately. On a cold offline launch, **Save for filing** stores up to 20 +account-bound title/description captures without selecting a repository or entering the +mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms +the same Gitea login, **Choose repository** restores the capture to the normal planning +and durable delivery flow. A different or unconfirmed account can only copy or discard +the private content. Issue capture and authored mobile actions (issue comments, pull-request comments, notification replies, and reviews) persist per-draft idempotency keys, so retrying after a timeout, reload, process restart, or handoff to another worker replays a confirmed result instead of posting duplicate content. Results diff --git a/frontend/dashboard.css b/frontend/dashboard.css index a6ac3ca..526cda5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; } .draft-preview { color:var(--muted); overflow-wrap:anywhere; } .draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; } .draft-actions button { min-height:44px; width:100%; } +.create-issue-actions button { min-height:44px; max-width:100%; } .my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); } .my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; } .my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index a0fd30c..98bd378 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -168,6 +168,11 @@ loadMilestones: item => issueController.loadMilestones(item), }); const issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage }); + const unfiledCaptures = createUnfiledCaptures({ + storage: localStorage, + getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(), + getCurrentLogin: () => activeFlushLogin, + }); let backgroundIssueSync = null; if ('indexedDB' in window) { const backgroundIssueStore = createIssueSyncStore(); @@ -619,8 +624,21 @@ refreshMyWorkView(); } + function listDrafts() { + const unfiled = unfiledCaptures.list().map(item => ({ + id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing', + title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'), + updated_at:item.savedAt, quarantined:item.quarantined, + ownership:item.quarantined ? 'Saved by ' + item.ownerLogin + + (activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '', + })); + return draftInbox.list().concat(unfiled).sort((left, right) => + Number(right.updated_at || 0) - Number(left.updated_at || 0) + ); + } + function refreshMyWorkView() { - lastDrafts = draftInbox.list(); + lastDrafts = listDrafts(); const partitioned = laterWork.partition(lastMyWork, { pruneMissing: !Object.values(workPagination).some(page => page?.has_more), }); @@ -687,6 +705,7 @@ const list = qs('#my-work-list'); list.innerHTML = lastDrafts.length ? lastDrafts.map((item, index) => { const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox'; + const isUnfiled = item.kind === 'unfiled-issue'; const outboxActions = item.quarantined ? '' + '' : @@ -698,11 +717,12 @@ '' + '' + '' : - '' + + '' + ''; - const state = isOutbox ? + const state = (isOutbox || isUnfiled) ? '' + (item.quarantined ? 'Identity protected' : - (item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + + (isUnfiled ? 'Needs filing' : item.status === 'attention' ? 'Needs attention' : 'Queued for sync')) + '' + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; return '
' + '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + @@ -715,7 +735,15 @@ button.addEventListener('click', () => { const item = lastDrafts[Number(button.dataset.draftIndex)]; if (!item) return; - if (item.kind === 'new-issue') openCreateIssueSheet(); + if (item.kind === 'unfiled-issue') { + try { + const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin); + issueCapture.saveDraft(resumed); + refreshMyWorkView(); + openCreateIssueSheet(); + qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.'; + } catch (error) { qs('#my-work-action-status').textContent = error.message; } + } else if (item.kind === 'new-issue') openCreateIssueSheet(); else if (item.route) workRoute.open(item.route); }); }); @@ -751,10 +779,11 @@ button.addEventListener('click', () => { if (!window.confirm('Discard this unfinished draft?')) return; const item = lastDrafts[Number(button.dataset.draftIndex)]; - if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); + if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id); + else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id); else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id); else if (item) draftInbox.discard(item.id); - lastDrafts = draftInbox.list(); + lastDrafts = listDrafts(); const count = qs('[data-work-count="draft"]'); if (count) count.textContent = lastDrafts.length; renderDrafts(); @@ -779,7 +808,7 @@ } function renderMyWork() { - lastDrafts = draftInbox.list(); + lastDrafts = listDrafts(); const draftCount = qs('[data-work-count="draft"]'); if (draftCount) draftCount.textContent = lastDrafts.length; if (selectedWorkFilter === 'draft') { @@ -2102,6 +2131,24 @@ } }); qs('#new-issue').addEventListener('click', openCreateIssueSheet); + qs('#save-unfiled-issue').addEventListener('click', () => { + const captureDraft = { + title: qs('#create-issue-title').value.trim(), + body: qs('#create-issue-body').value.trim(), + }; + try { + unfiledCaptures.save(captureDraft); + issueCapture.clearDraft(); + qs('#create-issue-title').value = ''; + qs('#create-issue-body').value = ''; + closeCreateIssueSheet(); + refreshMyWorkView(); + qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.'; + } catch (error) { + qs('#create-issue-status').textContent = error.message; + qs('#create-issue-title').focus(); + } + }); qs('#use-shared-content').addEventListener('click', () => { issueCapture.acceptSharedContent(); qs('#shared-content-conflict').hidden = true; diff --git a/frontend/index.html b/frontend/index.html index cfb81fa..0203188 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -335,6 +335,7 @@
The issue will be assigned to you.
+
@@ -506,6 +507,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 7bd8d25..b9bd972 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v28'; +const CACHE = 'stackchain-dashboard-shell-v29'; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const SHELL = [ BASE, @@ -15,6 +15,7 @@ const SHELL = [ BASE + 'static/search-preview.js', BASE + 'static/widgets.js', BASE + 'static/drafts.js', + BASE + 'static/unfiled-captures.js', BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', BASE + 'static/authored-outbox.js', diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js new file mode 100644 index 0000000..9323b11 --- /dev/null +++ b/frontend/unfiled-captures.js @@ -0,0 +1,65 @@ +function createUnfiledCaptures({ + storage, + getCaptureLogin = () => '', + getCurrentLogin = () => '', + createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2), + now = () => Date.now(), + maxItems = 20, +}) { + const storageKey = 'stackchain.unfiled-issues.v1'; + + function read() { + try { + const record = JSON.parse(storage?.getItem(storageKey) || 'null'); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => + item && typeof item.id === 'string' && typeof item.ownerLogin === 'string' && + typeof item.title === 'string' && item.title.trim() && typeof item.body === 'string' + ); + } catch (_error) { return []; } + } + + function write(items) { + storage?.setItem(storageKey, JSON.stringify({version:1, items})); + } + + function list() { + const currentLogin = String(getCurrentLogin() || '').trim(); + return read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt)) + .map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin})); + } + + function save(note) { + const title = String(note?.title || '').trim().slice(0, 255); + const body = String(note?.body || '').trim().slice(0, 10000); + if (!title) throw new Error('Add a title before saving.'); + const ownerLogin = String(getCaptureLogin() || '').trim(); + if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.'); + const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())}; + const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems); + write(items); + return item; + } + + function discard(id) { + const items = read(); + const remaining = items.filter(item => item.id !== id); + if (remaining.length === items.length) return false; + write(remaining); + return true; + } + + function resume(id, confirmedLogin) { + const item = read().find(candidate => candidate.id === id); + if (!item) throw new Error('This capture is no longer available.'); + if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) { + throw new Error('Reconnect with the account that saved this capture.'); + } + discard(id); + return {repository:'', title:item.title, body:item.body, labelIds:[]}; + } + + return {list, save, discard, resume}; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures; diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 940a861..1bee7c1 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -95,7 +95,7 @@ async function dispatchNotificationClick(route) {{ def test_strict_browser_assets_ship_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v28" in source + assert "stackchain-dashboard-shell-v29" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -217,6 +217,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/search-preview.js", "/dashboard/static/widgets.js", "/dashboard/static/drafts.js", + "/dashboard/static/unfiled-captures.js", "/dashboard/static/outbox-coordinator.js", "/dashboard/static/issue-outbox.js", "/dashboard/static/authored-outbox.js", diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py new file mode 100644 index 0000000..35ebbb4 --- /dev/null +++ b/tests/test_unfiled_captures.py @@ -0,0 +1,111 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from tests.dashboard_bundle import dashboard + + +UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js" + + +def run_node(script: str): + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +def test_unfiled_captures_keep_twenty_newest_account_bound_notes(): + script = f""" +const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); +const values = new Map(); +const storage = {{ + getItem:key => values.get(key) || null, + setItem:(key,value) => values.set(key,value), + removeItem:key => values.delete(key), +}}; +let sequence = 0; +const captures = createUnfiledCaptures({{ + storage, + getCaptureLogin:()=>'timmy', + getCurrentLogin:()=>'', + createId:()=>String(++sequence), + now:()=>1000 + sequence, +}}); +for (let index = 1; index <= 22; index += 1) {{ + captures.save({{title:'Note ' + index, body:'Context ' + index}}); +}} +const offline = captures.list(); +const restored = createUnfiledCaptures({{ + storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy' +}}).list(); +process.stdout.write(JSON.stringify({{offline, restored}})); +""" + output = run_node(script) + + assert len(output["offline"]) == 20 + assert output["offline"][0]["title"] == "Note 22" + assert output["offline"][-1]["title"] == "Note 3" + assert all(item["quarantined"] for item in output["offline"]) + assert all(not item["quarantined"] for item in output["restored"]) + assert output["restored"][0]["ownerLogin"] == "timmy" + + +def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note(): + script = f""" +const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}}; +let id = 0; +const captures = createUnfiledCaptures({{ + storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy', createId:()=>String(++id), now:()=>id +}}); +const first = captures.save({{title:'First',body:'One'}}); +const second = captures.save({{title:'Second',body:'Two'}}); +let mismatch = ''; +try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }} +const resumed = captures.resume(first.id, 'timmy'); +process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}})); +""" + output = run_node(script) + + assert output["mismatch"] == "Reconnect with the account that saved this capture." + assert output["resumed"] == {"repository": "", "title": "First", "body": "One", "labelIds": []} + assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]] + + +def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing(): + script = f""" +const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); +const values = new Map(); +const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; +const captures = createUnfiledCaptures({{storage,getCaptureLogin:()=>''}}); +const errors = []; +for (const note of [{{title:'',body:'context'}},{{title:'Work',body:'context'}}]) {{ + try {{ captures.save(note); }} catch (error) {{ errors.push(error.message); }} +}} +process.stdout.write(JSON.stringify({{errors,size:values.size}})); +""" + output = run_node(script) + + assert output == { + "errors": ["Add a title before saving.", "Offline identity is unavailable. Reconnect once before saving private work."], + "size": 0, + } + + +@pytest.mark.anyio +async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume_flow(): + html = await dashboard() + + assert '' in html + assert 'id="save-unfiled-issue"' in html + assert 'Save for filing' in html + assert "createUnfiledCaptures({" in html + assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html + assert "unfiledCaptures.save(captureDraft)" in html + assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html + assert "issueCapture.saveDraft(resumed)" in html + assert "item.kind === 'unfiled-issue'" in html + assert '.create-issue-actions button { min-height:44px;' in html + assert '@media(max-width:320px)' in html