diff --git a/README.md b/README.md
index 67ce077..7347f05 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ as unknown rather than unblocked. Completing any Today item now exposes a 10-sec
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
-A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
+A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index 8925a67..67d634f 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -288,6 +288,9 @@ textarea { resize: vertical; min-height: 120px; }
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
.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-progress-blocker p { margin:0; }
.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/dashboard.js b/frontend/dashboard.js
index dcfb481..8dffcfb 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1959,7 +1959,7 @@
return {
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
number:item.number, label:item.key || item.repository + '#' + item.number,
- title:item.title || '',
+ title:item.title || '', item,
};
}
const todayProgressView = createTodayProgressView({
@@ -1968,6 +1968,7 @@
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
+ moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
diff --git a/frontend/index.html b/frontend/index.html
index fc3e765..8d316aa 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1601,6 +1601,13 @@
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 eb3c4eb..5e3847f 100644
--- a/frontend/today-progress.js
+++ b/frontend/today-progress.js
@@ -129,7 +129,54 @@ maxLength = 2000, maxItems = 20 }) {
return admission;
}
- return { load, save, discard:identity => save(identity, ''), post };
+ async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
+ if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
+ if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
+ let record = read()[target.identity];
+ let admission = { background:true, alreadyAdmitted:true };
+ if (record?.blocker_pending !== true) {
+ const body = String(value ?? record?.body ?? '').trim();
+ if (!body) throw new Error('Describe what is blocking this Today item.');
+ const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
+ if (!save(target.identity, body, evidence)) {
+ throw new Error('Blocker update could not be saved on this device.');
+ }
+ record = read()[target.identity];
+ if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
+ admission = await admit({
+ kind:target.kind + '-comment', repository:target.repository, number:target.number,
+ body:record.body, operationId:record.operation_id,
+ ...(evidence.length ? {attachments:evidence} : {}),
+ });
+ const drafts = read();
+ if (drafts[target.identity]?.operation_id === record.operation_id) {
+ drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
+ write(drafts);
+ }
+ } else {
+ until = record.blocker_until;
+ }
+ try {
+ if (typeof completeEvidence === 'function') await completeEvidence();
+ } catch (_error) {
+ const error = new Error('Blocker is already queued; photo cleanup is pending.');
+ error.deliveryAdmitted = true;
+ throw error;
+ }
+ if (await transition(target, until) !== true) {
+ const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
+ error.deliveryAdmitted = true;
+ throw error;
+ }
+ const drafts = read();
+ if (drafts[target.identity]?.operation_id === record.operation_id) {
+ delete drafts[target.identity];
+ write(drafts);
+ }
+ return admission;
+ }
+
+ return { load, save, discard:identity => save(identity, ''), post, postBlocker };
}
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
@@ -247,7 +294,7 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
return activity;
}
-function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {} }) {
+function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
@@ -310,6 +357,43 @@ 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 wakeAt = new Date(returnInput?.value || '');
+ if (!body.value.trim()) {
+ status.textContent = 'Describe what is blocking this Today item.';
+ body.focus();
+ return;
+ }
+ if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
+ status.textContent = 'Choose a valid future return time.';
+ returnInput?.focus?.();
+ return;
+ }
+ blockerButton.disabled = true;
+ status.textContent = 'Posting blocker before moving Today…';
+ try {
+ await photos?.checkpoint?.();
+ const attachments = await photos?.serialize?.() || [];
+ const admission = await progress.postBlocker(
+ target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
+ );
+ onAdmitted(admission);
+ announce('Blocker queued, deferred to Later, and Today moved on.');
+ close();
+ } catch (error) {
+ status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
+ body.focus();
+ } finally {
+ blockerButton.disabled = false;
+ }
+ });
qs('#post-today-progress').addEventListener('click', async () => {
const target = currentTarget();
if (!target || target.identity !== openedTarget?.identity) {
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 00e0460..7b1c1d5 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -35,7 +35,7 @@ FEATURE_SOURCES = {
"today-timer": (
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
- "static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
+ "static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
diff --git a/tests/e2e/test_mobile_today_handoff_release.py b/tests/e2e/test_mobile_today_handoff_release.py
index 4342d81..b92c224 100644
--- a/tests/e2e/test_mobile_today_handoff_release.py
+++ b/tests/e2e/test_mobile_today_handoff_release.py
@@ -255,3 +255,70 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)
+
+
+def test_release_artifact_posts_blocker_defers_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))
+ thread = threading.Thread(target=fake.serve_forever, daemon=True)
+ thread.start()
+ fake_url = f"http://127.0.0.1:{fake.server_port}"
+ browser_errors: list[str] = []
+ try:
+ with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
+ browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
+ page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
+ page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
+ page.goto(origin + "/", wait_until="networkidle")
+ page.locator('input[name="device_label"]').fill("Blocker flow phone")
+ page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
+ page.locator("#submit-sign-in").click()
+ page.wait_for_url(origin + "/", wait_until="networkidle")
+ expect(page.locator("#my-work-status")).to_contain_text("2")
+
+ page.locator('[data-mobile-task="work"]').click()
+ page.locator("#plan-today-available").fill("90")
+ page.locator("#plan-today-available").press("Tab")
+ page.locator("#build-today-plan").click()
+ missing = page.locator("[data-plan-missing-estimate]")
+ expect(missing).to_have_count(2)
+ for _ in range(2):
+ missing.nth(0).fill("30")
+ missing.nth(0).press("Tab")
+ page.locator("#save-and-start-today").click()
+ expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
+ page.locator("#close-issue-sheet").click()
+ if page.locator("#plan-today-sheet").is_visible():
+ page.locator("#cancel-plan-today").click()
+ expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
+
+ 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("#today-blocker-return-at").fill("2099-08-19T09:00")
+ blocker = page.locator("#post-today-blocker")
+ bounds = blocker.bounding_box()
+ assert bounds and bounds["height"] >= 44
+ blocker.click()
+
+ expect(page.locator("#today-progress-sheet")).to_be_hidden()
+ expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
+ state = page.evaluate("""() => ({
+ later: JSON.parse(localStorage.getItem('stackchain.later-work.v1.timmy') || '{}'),
+ today: JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]'),
+ outbox: JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'),
+ })""")
+ assert state["today"] == ["issue:acme/mobile:42:"]
+ assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
+ comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
+ assert len(comments) == 1
+ assert comments[0]["number"] == 41
+ assert comments[0]["body"] == "Blocked waiting for the design owner"
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ assert browser_errors == []
+ browser.close()
+ finally:
+ fake.shutdown()
+ fake.server_close()
+ thread.join(timeout=5)
diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py
index 7bf8054..f3de8d0 100644
--- a/tests/test_frontend_bundle.py
+++ b/tests/test_frontend_bundle.py
@@ -74,6 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
+ assert b"function createDetailDefer" not in first.runtime_bytes
+ assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py
index bf8634e..a868896 100644
--- a/tests/test_today_progress.py
+++ b/tests/test_today_progress.py
@@ -179,6 +179,50 @@ process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,
assert output["after"] == ""
+def test_blocker_retry_moves_on_without_admitting_the_comment_twice():
+ 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:()=> 'blocker-op-1',
+ admit:async message=>{{admissions.push(message);return {{background:true}};}},
+}};
+let progress=createProgress(options);
+const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066}};
+const until='2026-08-19T09:00:00.000Z';
+const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
+let firstError=''; let admitted=false;
+try {{await progress.postBlocker(target,'Waiting for the API owner',[],null,until,transition);}}
+catch(caught){{firstError=caught.message;admitted=caught.deliveryAdmitted===true;}}
+progress=createProgress(options);
+transitionFails=false;
+const recovered=await progress.postBlocker(target,undefined,[],null,until,transition);
+process.stdout.write(JSON.stringify({{
+ firstError,admitted,admissions,transitions,recovered,after:progress.load(target.identity),stored:[...values.values()],
+}}));
+"""
+ output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
+ assert output["firstError"] == "Blocker was posted, but Today could not move on. Retry the planning step."
+ assert output["admitted"] is True
+ assert len(output["admissions"]) == 1
+ assert output["admissions"][0] == {
+ "kind": "issue-comment",
+ "repository": "stackchain/dashboard",
+ "number": 1066,
+ "body": "Waiting for the API owner",
+ "operationId": "blocker-op-1",
+ }
+ assert output["transitions"] == [
+ ["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
+ ["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
+ ]
+ assert output["recovered"]["alreadyAdmitted"] is True
+ assert output["after"] == ""
+ assert output["stored"] == []
+
+
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
script = f"""
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
@@ -281,6 +325,54 @@ const view=createView({{
}
+def test_progress_sheet_posts_blocker_then_defers_and_continues_exact_active_item():
+ 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'];
+const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
+const item={{kind:'issue',repository:'stackchain/dashboard',number:1066,title:'Blocked flow'}};
+const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066,label:'#1066',title:'Blocked flow',item}};
+const calls=[]; const announcements=[];
+const progress={{
+ load:()=>'',save:()=>true,
+ postBlocker:async(...args)=>{{calls.push(args);return {{background:true}};}},
+}};
+createView({{
+ progress,currentTarget:()=>target,qs:selector=>elements[selector],
+ photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},
+ moveOn:async(value,until)=>{{calls.push(['move-on',value.identity,until]);return true;}},
+ announce:value=>announcements.push(value),
+}});
+(async()=>{{
+ await elements['[data-mobile-today-update]'].click();
+ elements['#today-progress-body'].value='Waiting for API access';
+ elements['#today-blocker-return-at'].value='2099-08-19T09:00';
+ await elements['#post-today-blocker'].click();
+ process.stdout.write(JSON.stringify({{
+ posted:calls[0].slice(0,3),transitionResult:await calls[0][5](target,calls[0][4]),
+ moveCall:calls[1],announcements,closed:!elements['#today-progress-sheet'].open,
+ }}));
+}})().catch(error=>{{console.error(error);process.exit(1);}});
+"""
+ output = run_node(script)
+ assert output["posted"][0]["identity"] == "issue:stackchain/dashboard:1066:"
+ assert output["posted"][1] == "Waiting for API access"
+ assert output["posted"][2] == []
+ assert output["moveCall"][0:2] == ["move-on", "issue:stackchain/dashboard:1066:"]
+ assert output["moveCall"][2].startswith("2099-08-19T09:00")
+ assert output["transitionResult"] is True
+ assert output["announcements"] == ["Blocker queued, deferred to Later, and Today moved on."]
+ 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))});
@@ -473,6 +565,9 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
assert 'maxlength="2000"' in html
assert 'id="save-today-progress"' in html
assert 'id="post-today-progress"' in html
+ assert 'id="today-blocker-return-at"' in html
+ assert 'id="post-today-blocker"' in html
+ assert 'Post blocker & move on' in html
assert '' in html
assert '"static/today-progress.js"' in bundle
assert "workSession.target('continue')" in dashboard
@@ -485,6 +580,7 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
assert "voice:todayProgressVoice" in dashboard
assert "mountTodayProgressActivity" in dashboard
assert "activity:mountTodayProgressActivity" in dashboard
+ assert "moveOn:(target,until) => detailDefer.deferUntil(target.item,until)" in dashboard
assert "fetchReviewJson,actionHydrator" in dashboard
assert ".today-progress-activity-list" in css
assert ".today-progress-activity-actions button" in css