From 8b56618e62770f69f21bc265764d938a5f086e1b Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 25 Aug 2026 21:29:50 +0000 Subject: [PATCH] feat: recover progressive mobile captures (Closes #1409) --- frontend/dashboard.js | 1 + frontend/progressive-capture.js | 60 +++++++++++- tests/test_progressive_capture.py | 149 ++++++++++++++++++++++++++++-- 3 files changed, 200 insertions(+), 10 deletions(-) diff --git a/frontend/dashboard.js b/frontend/dashboard.js index f2d0cb2..ecc47dd 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -6430,6 +6430,7 @@ if (progressiveCaptureHandoff?.open && await ensureIssueCapture()) { issueCapture.saveDraft({repository:'', labelIds:[], title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body}); + progressiveCaptureHandoff.complete?.(); await openCreateIssueSheet(false); } qs('#create-issue-repository').addEventListener('change', event => { diff --git a/frontend/progressive-capture.js b/frontend/progressive-capture.js index 6200f31..dab9c72 100644 --- a/frontend/progressive-capture.js +++ b/frontend/progressive-capture.js @@ -31,6 +31,7 @@ function createProgressiveCapture({ let saving = null; let saved = false; let handedOff = false; + const checkpointKey = 'stackchain.progressive-capture.v1'; function listen(element, name, callback) { element?.addEventListener?.(name, callback); @@ -41,8 +42,58 @@ function createProgressiveCapture({ root?.classList.remove('open'); } + function readCheckpoints() { + try { + const record = JSON.parse(storage?.getItem(checkpointKey) || 'null'); + if (record?.version !== 1 || !Array.isArray(record.items)) return []; + return record.items.filter(item => item && typeof item.ownerLogin === 'string' && + typeof item.title === 'string' && typeof item.body === 'string'); + } catch (_error) { return []; } + } + + function readCheckpoint() { + const login = String(getLogin() || '').trim(); + if (!login) return null; + return readCheckpoints().find(item => item.ownerLogin === login) || null; + } + + function checkpoint() { + const ownerLogin = String(getLogin() || '').trim(); + if (!ownerLogin) return false; + try { + const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin); + items.push({ + ownerLogin, + title:String(title?.value || '').slice(0, 255), + body:String(body?.value || '').slice(0, 10000), + }); + storage?.setItem(checkpointKey, JSON.stringify({ + version:1, items, + })); + return true; + } catch (_error) { return false; } + } + + function clearCheckpoint(expected) { + const current = readCheckpoint(); + if (!current || current.title !== expected.title || current.body !== expected.body) return false; + try { + const ownerLogin = String(getLogin() || '').trim(); + const items = readCheckpoints().filter(item => item.ownerLogin !== ownerLogin); + if (items.length) storage?.setItem(checkpointKey, JSON.stringify({version:1, items})); + else storage?.removeItem(checkpointKey); + return true; + } catch (_error) { return false; } + } + function open(event) { saved = false; + const recovered = readCheckpoint(); + if (recovered) { + if (title) title.value = recovered.title; + if (body) body.value = recovered.body; + if (status) status.textContent = 'Unfinished capture restored from this phone.'; + } if (heading) heading.textContent = 'Capture work'; root?.classList.add('open'); title?.focus?.(); @@ -51,10 +102,10 @@ function createProgressiveCapture({ async function saveDraft() { if (saved || saving) return saving; save.disabled = true; - saving = Promise.resolve().then(() => captures.save({ - title:title?.value || '', body:body?.value || '', - })).then(() => { + const draft = {title:title?.value || '', body:body?.value || ''}; + saving = Promise.resolve().then(() => captures.save(draft)).then(() => { saved = true; + clearCheckpoint(draft); if (title) title.value = ''; if (body) body.value = ''; close(); @@ -94,6 +145,8 @@ function createProgressiveCapture({ listen(save, 'click', saveDraft); listen(file, 'click', fileNow); listen(cancel, 'click', close); + listen(title, 'input', checkpoint); + listen(body, 'input', checkpoint); return true; } @@ -104,6 +157,7 @@ function createProgressiveCapture({ open:Boolean(root?.classList.contains('open')), title:title?.value || '', body:body?.value || '', }; + state.complete = () => clearCheckpoint(state); root?.classList.remove('progressive-capture'); file.disabled = false; listeners.forEach(([element, name, callback]) => element?.removeEventListener?.(name, callback)); diff --git a/tests/test_progressive_capture.py b/tests/test_progressive_capture.py index 85313ba..58f18f6 100644 --- a/tests/test_progressive_capture.py +++ b/tests/test_progressive_capture.py @@ -28,8 +28,12 @@ const button = {dataset:{mobileTask:'new'}, addEventListener(name, callback){lis const save = {disabled:false, addEventListener(name, callback){listeners.set('save:'+name, callback);}, removeEventListener(name, callback){if(listeners.get('save:'+name)===callback) listeners.delete('save:'+name);}}; const file = {disabled:false, addEventListener(){}, removeEventListener(){}}; const cancel = {addEventListener(){}, removeEventListener(){}}; -const title = {value:'', focused:0, focus(){this.focused++;}}; -const body = {value:''}; +const field = name => ({value:'', focused:0, focus(){this.focused++;}, + addEventListener(event, callback){listeners.set(name+':'+event, callback);}, + removeEventListener(event, callback){if(listeners.get(name+':'+event)===callback) listeners.delete(name+':'+event);}, +}); +const title = field('title'); +const body = field('body'); const heading = {textContent:''}; const status = {textContent:''}; const classes = new Set(); @@ -43,15 +47,16 @@ const document = { querySelector(selector){return selector==='[data-mobile-task="new"]'?button:(nodes[selector]||null);}, }; const values = new Map(); -const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}; +const storage = {getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; const flow = createProgressiveCapture({document, storage, getLogin:()=> 'timmy', createCaptures:createUnfiledCaptures, createId:()=> 'draft-1', now:()=> 42}); flow.start(); listeners.get('new:click')({currentTarget:button}); -title.value='Production outage'; body.value='Investigate mobile reports'; +title.value='Production outage'; listeners.get('title:input')(); +body.value='Investigate mobile reports'; listeners.get('body:input')(); await listeners.get('save:click')(); await listeners.get('save:click')(); const stored=JSON.parse(values.get('stackchain.unfiled-issues.v1')); -console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()]})); +console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,heading:heading.textContent,status:status.textContent,items:stored.items,listeners:[...listeners.keys()],checkpoint:values.get('stackchain.progressive-capture.v1')||null})); """) assert result == { @@ -66,7 +71,136 @@ console.log(JSON.stringify({open:classes.has('open'),focused:title.focused,headi "body": "Investigate mobile reports", "savedAt": 42, }], - "listeners": ["new:click", "save:click"], + "listeners": ["new:click", "save:click", "title:input", "body:input"], + "checkpoint": None, + } + + +def test_failed_progressive_save_keeps_rendered_fields_and_checkpoint_for_retry(): + result = run_capture(r""" +const listeners={}; +const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){this.focused=true;}}); +const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); +const title=control('title'); const body=control('body'); const status={textContent:''}; const classes=new Set(); +const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; +const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, + '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, + '#cancel-new-issue':cancel,'#my-work-action-status':status}; +const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; +const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:()=>({save(){throw new Error('Phone storage is full.');}})}); +flow.start(); listeners['new:click'](); +title.value='Keep title'; listeners['title:input'](); body.value='Keep note'; listeners['body:input'](); +const saved=await listeners['save:click'](); +console.log(JSON.stringify({saved,open:classes.has('open'),title:title.value,body:body.value,status:status.textContent, + checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1'))})); +""") + + assert result == { + "saved": False, + "open": True, + "title": "Keep title", + "body": "Keep note", + "status": "Phone storage is full.", + "checkpoint": { + "version": 1, + "items": [{ + "ownerLogin": "timmy", + "title": "Keep title", + "body": "Keep note", + }], + }, + } + + +def test_progressive_capture_restores_latest_checkpoint_only_for_its_account(): + result = run_capture(r""" +const values = new Map(); +const storage = { + getItem:key=>values.get(key)||null, + setItem:(key,value)=>values.set(key,value), + removeItem:key=>values.delete(key), +}; +function mount(login) { + const listeners = new Map(); + const control = name => ({disabled:false,value:'', + addEventListener(event, callback){listeners.set(name+':'+event, callback);}, + removeEventListener(){},focus(){}, + }); + const button=control('new'); const save=control('save'); const file=control('file'); + const cancel=control('cancel'); const title=control('title'); const body=control('body'); + const status={textContent:''}; const classes=new Set(); + const root={classList:{add:name=>classes.add(name),remove:name=>classes.delete(name),contains:name=>classes.has(name)}}; + const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, + '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, + '#cancel-new-issue':cancel,'#my-work-action-status':status}; + const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; + const flow=createProgressiveCapture({document,storage,getLogin:()=>login,createCaptures:createUnfiledCaptures}); + flow.start(); + return {flow,listeners,title,body,status,button}; +} +const first=mount('timmy'); first.listeners.get('new:click')(); +first.title.value='Production outage'; first.listeners.get('title:input')?.(); +first.body.value='Latest phone note'; first.listeners.get('body:input')?.(); +const restored=mount('timmy'); restored.listeners.get('new:click')(); +const other=mount('alexander'); other.listeners.get('new:click')(); +other.title.value='Alexander note'; other.listeners.get('title:input')?.(); +const timmyAgain=mount('timmy'); timmyAgain.listeners.get('new:click')(); +console.log(JSON.stringify({ + restored:{title:restored.title.value,body:restored.body.value,status:restored.status.textContent}, + other:{title:other.title.value,body:other.body.value,status:other.status.textContent}, + timmyAgain:{title:timmyAgain.title.value,body:timmyAgain.body.value}, + checkpoint:JSON.parse(values.get('stackchain.progressive-capture.v1') || 'null'), +})); +""") + + assert result == { + "restored": { + "title": "Production outage", + "body": "Latest phone note", + "status": "Unfinished capture restored from this phone.", + }, + "other": {"title": "Alexander note", "body": "", "status": ""}, + "timmyAgain": {"title": "Production outage", "body": "Latest phone note"}, + "checkpoint": { + "version": 1, + "items": [ + {"ownerLogin": "timmy", "title": "Production outage", "body": "Latest phone note"}, + {"ownerLogin": "alexander", "title": "Alexander note", "body": ""}, + ], + }, + } + + +def test_hydration_handoff_clears_checkpoint_only_after_explicit_completion(): + result = run_capture(r""" +const listeners={}; +const control=name=>({disabled:false,value:'',addEventListener(event,callback){listeners[name+':'+event]=callback;},removeEventListener(){},focus(){}}); +const button=control('new'); const save=control('save'); const file=control('file'); const cancel=control('cancel'); +const title=control('title'); const body=control('body'); const root={classList:{add(){},remove(){},contains:()=>true}}; +const nodes={'#create-issue-sheet':root,'#create-issue-title':title,'#create-issue-body':body, + '#create-issue-heading':{textContent:''},'#save-unfiled-issue':save,'#file-new-issue':file, + '#cancel-new-issue':cancel,'#my-work-action-status':{textContent:''}}; +const document={querySelector:selector=>selector==='[data-mobile-task="new"]'?button:nodes[selector]}; +const values=new Map(); const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const flow=createProgressiveCapture({document,storage,getLogin:()=>'timmy',createCaptures:createUnfiledCaptures}); +flow.start(); title.value='Hand off'; listeners['title:input'](); body.value='Preserve me'; listeners['body:input'](); +const handoff=flow.handoff(); +const before=JSON.parse(values.get('stackchain.progressive-capture.v1')); +if (typeof handoff.complete === 'function') handoff.complete(); +console.log(JSON.stringify({before,after:values.get('stackchain.progressive-capture.v1')||null})); +""") + + assert result == { + "before": { + "version": 1, + "items": [{ + "ownerLogin": "timmy", + "title": "Hand off", + "body": "Preserve me", + }], + }, + "after": None, } @@ -109,7 +243,7 @@ console.log(JSON.stringify({filed,requests,open:classes.has('open'),status:statu "focused": 2, "title": "Keep this", "body": "Do not lose this note", - "before": ["cancel:click", "file:click", "new:click", "save:click"], + "before": ["cancel:click", "file:click", "new:click", "save:click", "title:input"], "after": [], "handoff": {"open": True, "title": "Keep this", "body": "Do not lose this note"}, "second": None, @@ -122,6 +256,7 @@ def test_hydrated_dashboard_adopts_open_progressive_capture_without_losing_field assert "const progressiveCaptureHandoff = window.stackchainProgressiveCapture?.handoff?.();" in source assert "issueCapture.saveDraft({repository:'', labelIds:[]," in source assert "title:progressiveCaptureHandoff.title, body:progressiveCaptureHandoff.body" in source + assert "progressiveCaptureHandoff.complete?.();" in source assert "await openCreateIssueSheet(false);" in source -- 2.43.0