diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 321efca..e147e83 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -290,6 +290,8 @@ textarea { resize: vertical; min-height: 120px; }
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
.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-blocker-presets { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; }
+.today-blocker-presets button { min-width:0; padding-inline:6px; }
.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; }
@@ -297,6 +299,13 @@ textarea { resize: vertical; min-height: 120px; }
.today-progress-panel .voice-conversation { margin-top:10px; }
.today-progress-panel .voice-conversation-controls button,
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
+.today-progress-panel.blocker-mode .today-progress-activity,
+.today-progress-panel.blocker-mode .voice-conversation,
+.today-progress-panel.blocker-mode .today-progress-evidence,
+.today-progress-panel.blocker-mode .today-progress-update-help,
+.today-progress-panel.blocker-mode .today-progress-actions { display:none; }
+.today-progress-panel.blocker-mode textarea { min-height:112px; }
+@media(max-width:359px) { .today-blocker-presets { grid-template-columns:1fr; } }
@media(max-width:359px) { .today-progress-actions { grid-template-columns:1fr; } }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
@@ -1116,7 +1125,8 @@ textarea { resize: vertical; min-height: 120px; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
- .mobile-today-hud [data-mobile-today-update] { grid-column:1 / -1; width:100%; }
+ .mobile-today-hud [data-mobile-today-update] { grid-column:1; width:100%; }
+ .mobile-today-hud [data-mobile-today-blocked] { grid-column:2; width:100%; }
.mobile-today-hud [data-today-break-open] { grid-column:1 / -1; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
diff --git a/frontend/index.html b/frontend/index.html
index 382a27b..875c249 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1537,6 +1537,7 @@
+
@@ -1556,7 +1557,7 @@
-
+
@@ -1600,12 +1601,17 @@
- Save privately on this device, or post to the exact Gitea item. Today keeps running either way.
+ Save privately on this device, or post to the exact Gitea item. Today keeps running either way.
Blocked?
+
+
+
+
+
Posts this update first, then defers the item and continues Today.
diff --git a/frontend/today-progress.js b/frontend/today-progress.js
index 3e74c9f..7e2037a 100644
--- a/frontend/today-progress.js
+++ b/frontend/today-progress.js
@@ -309,11 +309,15 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
return activity;
}
-function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null }) {
+function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null, now = () => new Date() }) {
const sheet = qs('#today-progress-sheet');
+ const panel = qs('.today-progress-panel');
+ const title = qs('#today-progress-title');
const body = qs('#today-progress-body');
+ const bodyLabel = qs('#today-progress-body-label');
const status = qs('#today-progress-status');
const launcher = qs('[data-mobile-today-update]');
+ const blockerLauncher = qs('[data-mobile-today-blocked]');
const blockerButton = qs('#post-today-blocker');
const blockerReturn = qs('#today-blocker-return-at');
const blockerRecovery = qs('#today-progress-blocker-recovery');
@@ -342,6 +346,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
const update = () => {
const target = currentTarget();
launcher.hidden = !target;
+ if (blockerLauncher) blockerLauncher.hidden = !target;
if (openedTarget && target?.identity !== openedTarget.identity) mentions?.dismiss?.();
};
const checkpoint = async () => {
@@ -362,28 +367,50 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
activity?.close?.();
mentions?.dismiss?.();
if (sheet.open) sheet.close();
+ panel?.classList?.remove('blocker-mode');
openedTarget = null;
};
- launcher.addEventListener('click', async () => {
+ const open = async blockerMode => {
const target = currentTarget();
if (!target) return;
mentions?.dismiss?.();
openedTarget = target;
+ panel?.classList?.[blockerMode ? 'add' : 'remove']('blocker-mode');
+ if (title) title.textContent = blockerMode ? 'Report blocker' : 'Add progress update';
+ if (bodyLabel) bodyLabel.textContent = blockerMode ? 'Blocker description' : 'Update';
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 {
- await voice?.open?.(target.identity);
- await photos?.open?.(target);
- await activity?.open?.(target);
- status.textContent = '';
+ if (!blockerMode) {
+ status.textContent = 'Restoring saved photo evidence…';
+ try {
+ await voice?.open?.(target.identity);
+ await photos?.open?.(target);
+ await activity?.open?.(target);
+ status.textContent = '';
+ }
+ catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
}
- catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
+ else status.textContent = '';
body.focus();
- });
+ };
+ launcher.addEventListener('click', () => open(false));
+ blockerLauncher?.addEventListener('click', () => open(true));
+
+ const setReturn = preset => {
+ const date = new Date(now());
+ if (preset === 'later-today') date.setHours(date.getHours() + 4);
+ else {
+ date.setDate(date.getDate() + 1);
+ date.setHours(9, 0, 0, 0);
+ }
+ blockerReturn.value = localDateTime(date);
+ };
+ qs('[data-today-blocker-preset="later-today"]')?.addEventListener('click', () => setReturn('later-today'));
+ qs('[data-today-blocker-preset="tomorrow"]')?.addEventListener('click', () => setReturn('tomorrow'));
+ qs('[data-today-blocker-custom]')?.addEventListener('click', () => blockerReturn?.focus?.());
qs('#cancel-today-progress').addEventListener('click', async () => {
if (await checkpoint()) close();
});
diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py
index 3d5f709..9f8f512 100644
--- a/tests/e2e/test_mobile_today_handoff_release.py
+++ b/tests/e2e/test_mobile_today_handoff_release.py
@@ -87,6 +87,26 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
if page.locator("#plan-today-sheet").is_visible():
page.locator("#cancel-plan-today").click()
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
+ blocked = page.locator("[data-mobile-today-blocked]")
+ expect(blocked).to_be_visible()
+ blocked_bounds = blocked.bounding_box()
+ assert blocked_bounds and blocked_bounds["height"] >= 44
+ blocked.click()
+ expect(page.locator("#today-progress-sheet")).to_be_visible()
+ expect(page.locator("#today-progress-title")).to_have_text("Report blocker")
+ expect(page.locator("#today-progress-target")).to_contain_text("Ship mobile capture")
+ expect(page.locator("#today-progress-body")).to_be_focused()
+ expect(page.locator("#today-progress-activity")).to_be_hidden()
+ expect(page.locator(".today-progress-evidence")).to_be_hidden()
+ for control in page.locator(".today-blocker-presets button, #post-today-blocker").all():
+ bounds = control.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ page.locator('[data-today-blocker-preset="later-today"]').click()
+ assert page.locator("#today-blocker-return-at").input_value()
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ page.locator("#cancel-today-progress").click()
+ expect(page.locator("#today-progress-sheet")).to_be_hidden()
+ expect(page.locator("[data-mobile-today-hud]")).to_contain_text("Ship mobile capture")
fake.activity_comments = {
41: [{
"id": 701,
diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py
index 09333f3..aab951b 100644
--- a/tests/test_today_progress.py
+++ b/tests/test_today_progress.py
@@ -329,6 +329,65 @@ const view=createView({{
}
+def test_blocked_hud_opens_a_focused_flow_with_quick_return_choices():
+ script = f"""
+const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
+class Element {{
+ constructor() {{
+ this.hidden=false;this.value='';this.textContent='';this.open=false;this.focused=false;this.listeners={{}};
+ const names=new Set();
+ this.classList={{add:name=>names.add(name),remove:name=>names.delete(name),contains:name=>names.has(name)}};
+ }}
+ addEventListener(name,callback) {{ this.listeners[name]=callback; }}
+ click() {{ return this.listeners.click?.(); }}
+ showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{ this.focused=true; }}
+}}
+const selectors=['#today-progress-sheet','.today-progress-panel','#today-progress-title','#today-progress-body',
+ '#today-progress-status','[data-mobile-today-update]','[data-mobile-today-blocked]',
+ '#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
+ '#today-blocker-return-at','#post-today-blocker','[data-today-blocker-preset="later-today"]',
+ '[data-today-blocker-preset="tomorrow"]','[data-today-blocker-custom]'];
+const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
+const target={{identity:'issue:stackchain/dashboard:1070:',kind:'issue',repository:'stackchain/dashboard',number:1070,label:'#1070',title:'Focused blocker'}};
+const calls=[];
+const view=createView({{
+ progress:{{load:()=> 'Waiting for access',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],
+ voice:{{open:async()=>calls.push('voice')}},photos:{{open:async()=>calls.push('photos')}},
+ activity:{{open:async()=>calls.push('activity')}},now:()=>new Date('2026-08-18T10:15:00Z'),
+}});
+(async()=>{{
+ const availability={{update:elements['[data-mobile-today-update]'].hidden,blocked:elements['[data-mobile-today-blocked]'].hidden}};
+ await elements['[data-mobile-today-blocked]'].click();
+ const opened={{
+ title:elements['#today-progress-title'].textContent,target:elements['#today-progress-target'].textContent,
+ focused:elements['#today-progress-body'].focused,mode:elements['.today-progress-panel'].classList.contains('blocker-mode'),
+ calls:[...calls],sheet:elements['#today-progress-sheet'].open,
+ }};
+ await elements['[data-today-blocker-preset="later-today"]'].click();
+ const laterToday=elements['#today-blocker-return-at'].value;
+ await elements['[data-today-blocker-preset="tomorrow"]'].click();
+ const tomorrow=elements['#today-blocker-return-at'].value;
+ elements['#today-blocker-return-at'].focused=false;
+ await elements['[data-today-blocker-custom]'].click();
+ process.stdout.write(JSON.stringify({{availability,opened,laterToday,tomorrow,customFocused:elements['#today-blocker-return-at'].focused}}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ assert run_node(script) == {
+ "availability": {"update": False, "blocked": False},
+ "opened": {
+ "title": "Report blocker",
+ "target": "#1070 · Focused blocker",
+ "focused": True,
+ "mode": True,
+ "calls": [],
+ "sheet": True,
+ },
+ "laterToday": "2026-08-18T14:15",
+ "tomorrow": "2026-08-19T09:00",
+ "customFocused": True,
+ }
+
+
def test_progress_sheet_dismisses_mentions_when_opened_switched_or_closed():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});