From c1c4ff8e3179afe766b2960777b79117f3478b1e Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 15 Aug 2026 01:12:10 +0000 Subject: [PATCH] feat: review ready mobile drafts in one tap (Closes #861) --- frontend/dashboard.css | 6 +- frontend/dashboard.js | 21 ++++--- frontend/draft-filing-session.js | 31 +++++++++- frontend/drafts.js | 11 +++- frontend/unfiled-captures.js | 19 +++++- tests/test_drafts.py | 13 ++-- tests/test_my_work.py | 2 +- tests/test_unfiled_captures.py | 100 +++++++++++++++++++++++++++++-- 8 files changed, 170 insertions(+), 33 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 57bccf7..e940e9f 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -282,7 +282,7 @@ textarea { resize: vertical; min-height: 120px; } .draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; } .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%; } +.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; } .delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; grid-column:1/-1; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; } .delivery-center h3, .delivery-center p { margin:0; } .delivery-center button { min-height:44px; flex:0 0 auto; } @@ -290,7 +290,7 @@ textarea { resize: vertical; min-height: 120px; } .draft-section { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; grid-column:1/-1; } .draft-section > h3 { grid-column:1/-1; margin:8px 0 0; } .create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); } -.create-issue-actions button { min-height:44px; max-width:100%; width:100%; } +.create-issue-actions button { min-height:44px; width:100%; } .create-issue-actions #create-issue-status { grid-column:1/-1; } .create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; } .create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; } @@ -298,13 +298,11 @@ textarea { resize: vertical; min-height: 120px; } .draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); } .draft-filing-session[hidden] { display:none; } .draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; } -.draft-filing-session-actions button { min-height:44px; 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); } .agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; } .agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; } .agenda-replan-launch p { margin:4px 0 0; } .agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; } -.agenda-replan-actions button { min-height:44px; width:100%; } .agenda-replan-actions label { grid-column:1/-1; } .agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; } .protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c76417e..c754870 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -536,9 +536,11 @@ ); const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)}); dFS.attach(qs, { - captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController, + captures:unfiledCaptures, capture:()=>issueCapture, attachment:createIssueAttachmentController, getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; }, openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode, + review:r=>loadIssueFilingMetadata(r.repository,r) + .then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates), }); let rUC = ''; let backgroundIssueSync = null; @@ -2655,19 +2657,16 @@ '' + '' : '' + + (isUnfiled ? item.action : 'Resume draft') + '' + ''; - const state = (isOutbox || isUnfiled) ? - '' + (item.quarantined ? 'Identity protected' : - (isUnfiled ? 'Needs filing' : item.status === 'completion' ? 'Created · ready to start' : - item.status === 'attention' ? 'Needs attention' : item.status === 'sending' ? 'Sending' : - item.status === 'authorization' ? 'Awaiting authorization' : 'Queued for sync')) + '' + + const state = isOutbox ? + '' + draftInbox.deliveryLabel(item) + '' + (item.ownership ? '
' + escapeHtml(item.ownership) + '
' : '') : ''; const attempt = item.last_attempt_error ? 'Last attempt ' + escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '' : ''; const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : ''; return '
' + - '' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '' + + '' + escapeHtml([item.label, item.repository, item.details].filter(Boolean).join(' · ')) + '' + '' + escapeHtml(item.title) + '' + '' + escapeHtml(item.preview || 'Unfinished draft') + '' + state + attempt + 'Saved ' + escapeHtml(fmt(item.updated_at)) + '' + @@ -2705,8 +2704,7 @@ if (!item) return; if (item.kind === 'unfiled-issue') { try { - dFS.start(item.capture_id); - await dFS.nextCapture(); + await dFS.open(item.capture_id, item.ready ? button : null); } 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); @@ -5326,7 +5324,8 @@ todayCapacity: startCapacity, } : {}), }; - filingReview.open({draft: durableDraft, intent}, event.submitter || qs('#submit-new-issue')); + filingReview.open({draft: durableDraft, intent}, + dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')); qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.'; } catch (error) { qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.'; diff --git a/frontend/draft-filing-session.js b/frontend/draft-filing-session.js index 4193fb1..80d092a 100644 --- a/frontend/draft-filing-session.js +++ b/frontend/draft-filing-session.js @@ -1,6 +1,7 @@ function createDraftFilingSession({list}) { let orderedIds = []; let currentId = ''; + let reviewTrigger = null; function visibleIds() { const available = new Set(list().map(item => item.id)); @@ -49,18 +50,42 @@ function createDraftFilingSession({list}) { const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext}; session.attach = (qs, dependencies) => { - const openCapture = async captureId => { + const openCapture = async (captureId, trigger = null) => { const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin()); - dependencies.issueCapture.saveDraft(resumed); + const issueCapture = dependencies.capture?.() || dependencies.issueCapture; + issueCapture.saveDraft(resumed); dependencies.setResumedId(captureId); - await dependencies.openSheet(); + await dependencies.openSheet(false); const evidence = resumed.attachments || resumed.attachment; dependencies.attachment[evidence ? 'restore' : 'clear'](evidence); dependencies.setFilingMode(true); session.render(); + if (trigger) { + await dependencies.review(resumed); + reviewTrigger = trigger; + const submitter = resumed.completionIntent === 'create-and-start' ? + qs('#create-and-start-issue') : qs('#submit-new-issue'); + const disabled = submitter.disabled; + submitter.disabled = false; + qs('#create-issue-form').requestSubmit(submitter); + submitter.disabled = disabled; + } }; return Object.assign(session, { nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; }, + reviewCapture(captureId, trigger) { + session.start(captureId); + return openCapture(captureId, trigger); + }, + open(captureId, trigger = null) { + session.start(captureId); + return openCapture(captureId, trigger); + }, + takeTrigger() { + const trigger = reviewTrigger; + reviewTrigger = null; + return trigger; + }, render() { const state = session.current(); qs('#draft-filing-session').hidden = !state; diff --git a/frontend/drafts.js b/frontend/drafts.js index c62e9ba..37d0e4e 100644 --- a/frontend/drafts.js +++ b/frontend/drafts.js @@ -251,7 +251,16 @@ function createDraftInbox({ storage, getCurrentLogin = () => '', now = () => Dat return { drafts, deliveries, counts, retryable }; } - return { list, discard, partition }; + function deliveryLabel(item) { + if (item.quarantined) return 'Identity protected'; + if (item.status === 'completion') return 'Created · ready to start'; + if (item.status === 'attention') return 'Needs attention'; + if (item.status === 'sending') return 'Sending'; + if (item.status === 'authorization') return 'Awaiting authorization'; + return 'Queued for sync'; + } + + return { list, discard, partition, deliveryLabel }; } if (typeof module !== 'undefined' && module.exports) module.exports = createDraftInbox; diff --git a/frontend/unfiled-captures.js b/frontend/unfiled-captures.js index 8da9c0f..a6e71a1 100644 --- a/frontend/unfiled-captures.js +++ b/frontend/unfiled-captures.js @@ -4,8 +4,18 @@ function withFilingEstimate(draft, value) { } function unfiledDraftSummary(item) { - return {label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing', - repository:item.filingPlan?.repository || ''}; + const plan = item.filingPlan || {}; + const ready = Boolean(plan.repository); + const startsToday = plan.completionIntent === 'create-and-start'; + const evidenceCount = Number(item.attachmentCount || (item.hasAttachment ? 1 : 0)); + const evidence = evidenceCount ? `${evidenceCount} screenshot${evidenceCount === 1 ? '' : 's'}` : 'No screenshots'; + return { + label:ready ? 'Ready to file' : 'Needs filing', + repository:plan.repository || '', ready, + action:ready ? (startsToday ? 'Review & start' : 'Review & file') : 'Choose repository', + details:[startsToday ? 'Create & start' : 'Create issue', + plan.estimateMinutes ? `${plan.estimateMinutes} min` : '', evidence].filter(Boolean).join(' · '), + }; } function unfiledSavedMessage(item) { @@ -409,4 +419,7 @@ function createUnfiledCaptures({ subscribe:listener => (listeners.add(listener), () => listeners.delete(listener))}; } -if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures; +if (typeof module !== 'undefined' && module.exports) { + module.exports = createUnfiledCaptures; + module.exports.summary = unfiledDraftSummary; +} diff --git a/tests/test_drafts.py b/tests/test_drafts.py index ca3f66e..6c301a7 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -270,13 +270,17 @@ const values = new Map([ ]); const storage = {{get length(){{return values.size}},key:i=>Array.from(values.keys())[i]||null,getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}}; const inbox = createDraftInbox({{storage,getCurrentLogin:()=>'timmy'}}); -process.stdout.write(JSON.stringify(inbox.partition())); +const partition=inbox.partition(); +process.stdout.write(JSON.stringify({{...partition,labels:partition.deliveries.map(inbox.deliveryLabel)}})); """ output = run_node(script) assert len(output["drafts"]) == 1 assert len(output["deliveries"]) == 5 assert output["counts"] == {"waiting": 2, "sending": 1, "attention": 2, "authorization": 0} + assert output["labels"] == [ + "Needs attention", "Identity protected", "Needs attention", "Sending", "Queued for sync" + ] assert [item["outbox_id"] for item in output["retryable"]] == ["waiting"] waiting = next(item for item in output["deliveries"] if item["outbox_id"] == "waiting") assert waiting["last_attempt_at"] == 90 @@ -293,7 +297,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane(): assert 'class="draft-resume"' in html assert 'class="draft-discard"' in html assert 'Discard this unfinished draft?' in html - assert '.draft-actions button { min-height:44px;' in html + assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html assert 'createDraftInbox({ storage: localStorage' in html assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html assert "Verified not posted — retry" in html @@ -316,7 +320,8 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html assert "deliveryCenter.retryable.length" in html - assert "item.status === 'sending' ? 'Sending'" in html + assert "draftInbox.deliveryLabel(item)" in html + assert "if (item.status === 'sending') return 'Sending'" in DRAFTS.read_text() assert "item.last_attempt_error ? 'Last attempt '" in html assert '.delivery-center { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;' in html assert '.delivery-center button { min-height:44px;' in html @@ -330,7 +335,7 @@ async def test_mobile_dashboard_requires_an_explicit_authorize_and_close_gesture assert '>Authorize & close' in html assert "list.querySelectorAll('.draft-authorize')" in html assert "authoredOutbox.retry(item.outbox_id, activeFlushLogin)" in html - assert "item.status === 'authorization' ? 'Awaiting authorization'" in html + assert "if (item.status === 'authorization') return 'Awaiting authorization'" in DRAFTS.read_text() @pytest.mark.anyio diff --git a/tests/test_my_work.py b/tests/test_my_work.py index a67f52c..8920599 100644 --- a/tests/test_my_work.py +++ b/tests/test_my_work.py @@ -4246,7 +4246,7 @@ async def test_every_mobile_repository_selection_uses_the_resilient_metadata_loa assert "renderLabels(repository, metadata.labels" in source assert "renderMilestones(repository, metadata.milestones" in source assert "templatePicker.render(repository, metadata.templates" in source - assert html.count("loadIssueFilingMetadata(") == 4 + assert html.count("loadIssueFilingMetadata(") == 5 assert "loadIssueLabels(repository);\n loadIssueMilestones(repository);" not in html assert "loadIssueLabels(event.target.value).then" not in html assert "loadIssueLabels(qs('#create-issue-repository').value" not in html diff --git a/tests/test_unfiled_captures.py b/tests/test_unfiled_captures.py index 19e1fd2..eaf8b59 100644 --- a/tests/test_unfiled_captures.py +++ b/tests/test_unfiled_captures.py @@ -60,6 +60,78 @@ session.start('planned'); assert run_node(script) == [[{"filename": "one.png"}, {"filename": "two.png"}]] +def test_ready_draft_summary_exposes_truthful_mobile_action_and_plan_details(): + script = f""" +const createUnfiledCaptures=require({json.dumps(str(UNFILED))}); +const planned=createUnfiledCaptures.summary({{ + filingPlan:{{repository:'stackchain/dashboard',completionIntent:'create-and-start',estimateMinutes:45}}, + attachmentCount:2, +}}); +const minimal=createUnfiledCaptures.summary({{}}); +process.stdout.write(JSON.stringify({{planned,minimal}})); +""" + + assert run_node(script) == { + "planned": { + "label": "Ready to file", + "repository": "stackchain/dashboard", + "ready": True, + "action": "Review & start", + "details": "Create & start · 45 min · 2 screenshots", + }, + "minimal": { + "label": "Needs filing", + "repository": "", + "ready": False, + "action": "Choose repository", + "details": "Create issue · No screenshots", + }, + } + + +def test_ready_draft_opens_final_review_with_restored_intent_and_evidence(): + script = f""" +const createDraftFilingSession=require({json.dumps(str(DRAFT_SESSION))}); +const events=[]; +const trigger={{id:'ready-action'}}; +const resumed={{repository:'o/r',title:'Planned',body:'Evidence',labelIds:[7], + completionIntent:'create-and-start',estimateMinutes:30, + attachments:[{{filename:'one.png'}},{{filename:'two.png'}}]}}; +const elements=new Map(); +const qs=selector=>{{ + if(!elements.has(selector)) elements.set(selector,{{id:selector.slice(1),hidden:false,disabled:false,textContent:'',addEventListener:()=>{{}}, + requestSubmit:button=>events.push(['review',resumed,button.id])}}); + return elements.get(selector); +}}; +const session=createDraftFilingSession({{list:()=>[{{id:'planned'}}]}}); +session.attach(qs,{{ + captures:{{resume:async()=>resumed}}, + capture:()=>({{saveDraft:value=>events.push(['save',value])}}), + attachment:{{restore:value=>events.push(['evidence',value]),clear:()=>{{}}}}, + getLogin:()=>'timmy',setResumedId:id=>events.push(['source',id]), + openSheet:async()=>events.push(['sheet']),setFilingMode:()=>events.push(['filing']), + review:async()=>{{events.push(['metadata']);events.push(['duplicates']);}}, +}}); +session.start('planned'); +(async()=>{{await session.reviewCapture('planned',trigger);process.stdout.write(JSON.stringify({{ + events,origin:session.takeTrigger().id +}}));}})(); +""" + + output = run_node(script) + events = output["events"] + assert [event[0] for event in events] == [ + "save", "source", "sheet", "evidence", "filing", "metadata", + "duplicates", "review" + ] + assert events[-1][1]["labelIds"] == [7] + assert events[-1][1]["attachments"] == [ + {"filename": "one.png"}, {"filename": "two.png"} + ] + assert events[-1][2] == "create-and-start-issue" + assert output["origin"] == "ready-action" + + def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced(): script = f""" const createUnfiledCaptures = require({json.dumps(str(UNFILED))}); @@ -599,7 +671,7 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume assert "await unfiledCaptures.completeResume(rUC)" in html assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html - assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() + assert "issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text() assert "item.kind === 'unfiled-issue'" in html assert '.create-issue-actions button { min-height:44px;' in html assert '.draft-card:focus-visible' in html @@ -618,11 +690,28 @@ async def test_mobile_planned_draft_can_be_saved_and_resumed_without_replanning( assert "qs('#create-issue-estimate').value = captureDraft.estimateMinutes || '';" in html assert "qs('#file-new-issue').hidden = enabled" in feature assert "qs('.create-issue-capture-actions').hidden = false" in feature - assert "label:item.filingPlan?.repository ? 'Ready to file' : 'Needs filing'" in feature - assert "repository:item.filingPlan?.repository || ''" in feature + assert "label:ready ? 'Ready to file' : 'Needs filing'" in feature + assert "repository:plan.repository || ''" in feature assert "Saved planned Draft" in feature +@pytest.mark.anyio +async def test_mobile_ready_draft_card_opens_validated_final_review_in_one_tap(): + html = await dashboard() + + assert "isUnfiled ? item.action" in html + assert "[item.label, item.repository, item.details].filter(Boolean).join(' · ')" in html + assert "escapeHtml([item.label" in html + assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html + assert "capture:()=>issueCapture" in html + assert "review:r=>loadIssueFilingMetadata(r.repository,r)" in html + assert ".then(()=>issueCapture.findDuplicates(currentIssueCaptureDraft())).then(renderIssueDuplicates)" in html + assert "qs('#create-issue-form').requestSubmit(submitter)" in DRAFT_SESSION.read_text() + assert "dFS.takeTrigger() || event.submitter || qs('#submit-new-issue')" in html + assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px; width:100%; }' in html + assert '@media(max-width:320px)' in html + + @pytest.mark.anyio async def test_mobile_drafts_expose_a_safe_sequential_filing_session(): html = await dashboard() @@ -632,13 +721,12 @@ async def test_mobile_drafts_expose_a_safe_sequential_filing_session(): assert 'id="draft-filing-progress"' in html assert 'id="previous-draft"' in html assert 'id="skip-draft"' in html - assert "dFS.start(item.capture_id)" in html - assert "await dFS.nextCapture()" in html + assert "await dFS.open(item.capture_id, item.ready ? button : null)" in html assert "await dFS.advance(fS.id)" in html feature = DRAFT_SESSION.read_text() assert "Draft ${state.position} of ${state.total}" in feature assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature - assert '.draft-filing-session-actions button { min-height:44px;' in html + assert '.draft-actions button, .draft-filing-session-actions button, .agenda-replan-actions button { min-height:44px;' in html assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html