diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 67d634f..321efca 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -291,6 +291,7 @@ textarea { resize: vertical; min-height: 120px; } .today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; } .today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; } .today-progress-blocker p { margin:0; } +.today-progress-blocker .today-progress-blocker-recovery { padding:8px; border-radius:8px; background:#3a270d; color:#fde68a; font-weight:700; } .today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; } .today-progress-actions button { width:100%; } .today-progress-panel .voice-conversation { margin-top:10px; } diff --git a/frontend/index.html b/frontend/index.html index 8d316aa..382a27b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1603,6 +1603,7 @@

Save privately on this device, or post to the exact Gitea item. Today keeps running either way.

Blocked? + diff --git a/frontend/today-progress.js b/frontend/today-progress.js index 5e3847f..3e74c9f 100644 --- a/frontend/today-progress.js +++ b/frontend/today-progress.js @@ -154,7 +154,16 @@ maxLength = 2000, maxItems = 20 }) { write(drafts); } } else { - until = record.blocker_until; + const replacementUntil = String(until || '').trim(); + until = replacementUntil || record.blocker_until; + if (replacementUntil && replacementUntil !== record.blocker_until) { + const drafts = read(); + if (drafts[target.identity]?.operation_id === record.operation_id) { + drafts[target.identity] = { ...drafts[target.identity], blocker_until:replacementUntil }; + write(drafts); + record = drafts[target.identity]; + } + } } try { if (typeof completeEvidence === 'function') await completeEvidence(); @@ -176,7 +185,13 @@ maxLength = 2000, maxItems = 20 }) { return admission; } - return { load, save, discard:identity => save(identity, ''), post, postBlocker }; + function blockerRecovery(identity) { + if (!validIdentity(identity)) return null; + const record = read()[identity]; + return record?.blocker_pending === true ? { pending:true, until:String(record.blocker_until || '') } : null; + } + + return { load, save, discard:identity => save(identity, ''), post, postBlocker, blockerRecovery }; } function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) { @@ -299,8 +314,31 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a const body = qs('#today-progress-body'); const status = qs('#today-progress-status'); const launcher = qs('[data-mobile-today-update]'); + const blockerButton = qs('#post-today-blocker'); + const blockerReturn = qs('#today-blocker-return-at'); + const blockerRecovery = qs('#today-progress-blocker-recovery'); + const saveButton = qs('#save-today-progress'); + const postButton = qs('#post-today-progress'); let openedTarget = null; + const localDateTime = value => { + const date = new Date(value || ''); + if (!Number.isFinite(date.getTime())) return ''; + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + }; + const showBlockerRecovery = recovery => { + const pending = recovery?.pending === true; + body.disabled = pending; + saveButton.disabled = pending; + postButton.disabled = pending; + if (blockerButton) blockerButton.textContent = pending ? 'Finish moving on' : 'Post blocker & move on'; + if (blockerReturn) blockerReturn.value = pending ? localDateTime(recovery.until) : ''; + if (blockerRecovery) { + blockerRecovery.hidden = !pending; + blockerRecovery.textContent = pending ? 'Blocker queued—finish moving on. The posted update cannot be changed.' : ''; + } + }; + const update = () => { const target = currentTarget(); launcher.hidden = !target; @@ -334,6 +372,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a openedTarget = target; qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : ''); body.value = progress.load(target.identity); + showBlockerRecovery(progress.blockerRecovery?.(target.identity)); status.textContent = 'Restoring saved photo evidence…'; sheet.showModal(); try { @@ -357,14 +396,13 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a announce('Progress update saved privately to this Today item.'); close(); }); - const blockerButton = qs('#post-today-blocker'); blockerButton?.addEventListener('click', async () => { const target = currentTarget(); if (!target || target.identity !== openedTarget?.identity) { status.textContent = 'The active Today item changed. Close and open its update again.'; return; } - const returnInput = qs('#today-blocker-return-at'); + const returnInput = blockerReturn; const wakeAt = new Date(returnInput?.value || ''); if (!body.value.trim()) { status.textContent = 'Describe what is blocking this Today item.'; diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py index b92c224..3d5f709 100644 --- a/tests/e2e/test_mobile_today_handoff_release.py +++ b/tests/e2e/test_mobile_today_handoff_release.py @@ -257,7 +257,7 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path: fake_thread.join(timeout=5) -def test_release_artifact_posts_blocker_defers_and_opens_next_mobile_issue(tmp_path: Path): +def test_release_artifact_recovers_admitted_blocker_after_reload_and_opens_next_mobile_issue(tmp_path: Path): archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz")) assert len(archives) == 1 fake = FakeGiteaServer(("127.0.0.1", 0)) @@ -296,8 +296,44 @@ def test_release_artifact_posts_blocker_defers_and_opens_next_mobile_issue(tmp_p page.locator("[data-mobile-today-update]").click() expect(page.locator("#today-progress-sheet")).to_be_visible() page.locator("#today-progress-body").fill("Blocked waiting for the design owner") + page.locator("#post-today-progress").click() + expect(page.locator("#today-progress-sheet")).to_be_hidden() + page.locator("[data-today-break-open]").click() + page.locator('[data-today-break-minutes="5"]').click() + expect(page.locator("#resume-today-break")).to_be_visible() + + page.evaluate("""() => { + localStorage.setItem('stackchain.today-progress.v1.timmy', JSON.stringify({ + version: 1, + drafts: { + 'issue:acme/mobile:41:': { + body: 'Blocked waiting for the design owner', + operation_id: 'already-admitted-blocker', + blocker_pending: true, + blocker_until: '2000-01-02T03:04:00.000Z' + } + } + })); + }""") + page.reload(wait_until="networkidle") + expect(page.locator("#my-work-status")).to_contain_text("2") + if page.locator("#issue-sheet").get_attribute("class") == "issue-sheet open": + page.locator("#close-issue-sheet").click() + if page.locator("#plan-today-sheet").is_visible(): + page.locator("#cancel-plan-today").click() + expect(page.locator("#resume-today-break")).to_be_visible() + page.locator("#resume-today-break").click() + expect(page.locator("#issue-sheet")).to_have_class("issue-sheet open") + page.locator("#close-issue-sheet").click() + page.locator("[data-mobile-today-update]").click() + expect(page.locator("#today-progress-sheet")).to_be_visible() + expect(page.locator("#today-progress-blocker-recovery")).to_be_visible() + expect(page.locator("#today-progress-blocker-recovery")).to_contain_text("Blocker queued") + expect(page.locator("#today-progress-body")).to_be_disabled() + expect(page.locator("#today-blocker-return-at")).to_have_value("2000-01-02T03:04") page.locator("#today-blocker-return-at").fill("2099-08-19T09:00") blocker = page.locator("#post-today-blocker") + expect(blocker).to_have_text("Finish moving on") bounds = blocker.bounding_box() assert bounds and bounds["height"] >= 44 blocker.click() diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py index a868896..09333f3 100644 --- a/tests/test_today_progress.py +++ b/tests/test_today_progress.py @@ -223,6 +223,46 @@ process.stdout.write(JSON.stringify({{ assert output["stored"] == [] +def test_admitted_blocker_recovery_restores_and_replaces_its_planning_time_without_readmission(): + script = f""" +const createProgress = require({json.dumps(str(TODAY_PROGRESS))}); +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 admissions=[]; const transitions=[]; let transitionFails=true; +const options={{ + storage,getLogin:()=> 'timmy',makeId:()=> 'stable-blocker-op', + admit:async message=>{{admissions.push(message);return {{background:true}};}}, +}}; +let progress=createProgress(options); +const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068}}; +const expired='2026-08-18T08:00:00.000Z'; +const replacement='2026-08-19T09:30:00.000Z'; +const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}}; +try {{await progress.postBlocker(target,'Waiting for production access',[],null,expired,transition);}} catch (_error) {{}} +progress=createProgress(options); +const restored=progress.blockerRecovery(target.identity); +transitionFails=false; +const recovered=await progress.postBlocker(target,'changed text must not be admitted',[],null,replacement,transition); +process.stdout.write(JSON.stringify({{ + restored,recovered,admissions,transitions,after:progress.blockerRecovery(target.identity),body:progress.load(target.identity), +}})); +""" + output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})") + assert output["restored"] == { + "pending": True, + "until": "2026-08-18T08:00:00.000Z", + } + assert len(output["admissions"]) == 1 + assert output["admissions"][0]["body"] == "Waiting for production access" + assert output["transitions"] == [ + ["issue:stackchain/dashboard:1068:", "2026-08-18T08:00:00.000Z"], + ["issue:stackchain/dashboard:1068:", "2026-08-19T09:30:00.000Z"], + ] + assert output["recovered"]["alreadyAdmitted"] is True + assert output["after"] is None + assert output["body"] == "" + + def test_progress_rejects_inactive_or_unsupported_targets_without_mutation(): script = f""" const createProgress = require({json.dumps(str(TODAY_PROGRESS))}); @@ -373,6 +413,59 @@ createView({{ assert output["closed"] is True +def test_progress_sheet_restores_admitted_blocker_recovery_and_accepts_a_new_planning_time(): + script = f""" +const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))}); +class Element {{ + constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }} + addEventListener(name,callback) {{ this.listeners[name]=callback; }} + click() {{ return this.listeners.click?.(); }} + showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}} +}} +const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]', + '#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress', + '#today-blocker-return-at','#post-today-blocker','#today-progress-blocker-recovery']; +const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()])); +const target={{identity:'issue:stackchain/dashboard:1068:',kind:'issue',repository:'stackchain/dashboard',number:1068,label:'#1068',title:'Recover blocker'}}; +const calls=[]; +const progress={{ + load:()=> 'Waiting for production access',save:()=>true, + blockerRecovery:()=>({{pending:true,until:'2000-01-02T03:04:00.000Z'}}), + postBlocker:async(...args)=>{{calls.push(args);return {{background:true,alreadyAdmitted:true}};}}, +}}; +createView({{ + progress,currentTarget:()=>target,qs:selector=>elements[selector], + photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},moveOn:async()=>true, +}}); +(async()=>{{ + await elements['[data-mobile-today-update]'].click(); + const restored={{ + body:elements['#today-progress-body'].value,bodyDisabled:elements['#today-progress-body'].disabled, + until:elements['#today-blocker-return-at'].value,recoveryHidden:elements['#today-progress-blocker-recovery'].hidden, + recoveryText:elements['#today-progress-blocker-recovery'].textContent,button:elements['#post-today-blocker'].textContent, + saveDisabled:elements['#save-today-progress'].disabled,postDisabled:elements['#post-today-progress'].disabled, + }}; + elements['#today-blocker-return-at'].value='2099-08-19T09:30'; + await elements['#post-today-blocker'].click(); + process.stdout.write(JSON.stringify({{restored,posted:{{body:calls[0][1],until:calls[0][4]}},closed:!elements['#today-progress-sheet'].open}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + output = run_node(script) + assert output["restored"] == { + "body": "Waiting for production access", + "bodyDisabled": True, + "until": "2000-01-02T03:04", + "recoveryHidden": False, + "recoveryText": "Blocker queued—finish moving on. The posted update cannot be changed.", + "button": "Finish moving on", + "saveDisabled": True, + "postDisabled": True, + } + assert output["posted"]["body"] == "Waiting for production access" + assert output["posted"]["until"].startswith("2099-08-19T09:30") + assert output["closed"] is True + + def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering(): script = f""" const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});