diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index f512ef5..1e527a5 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -269,6 +269,16 @@ textarea { resize: vertical; min-height: 120px; }
.today-break-status:has(> span[hidden]) { display:none; }
.today-break-status button { min-height:44px; min-width:112px; }
@media(max-width:359px) { .today-break-actions { grid-template-columns:1fr; } }
+.today-progress-sheet { position:fixed; inset:0; width:100%; max-width:none; height:100%; max-height:none; margin:0; padding:0; border:0; background:rgba(5,12,21,.82); color:#e5e7eb; }
+.today-progress-sheet::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
+.today-progress-panel { position:absolute; left:0; right:0; bottom:0; box-sizing:border-box; width:min(620px,100%); max-height:100dvh; margin:auto; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
+.today-progress-panel header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
+.today-progress-panel h2, .today-progress-panel p { margin-top:0; }
+.today-progress-panel header button, .today-progress-actions button { min-height:44px; }
+.today-progress-panel textarea { box-sizing:border-box; width:100%; min-height:120px; resize:vertical; }
+.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
+.today-progress-actions button { width:100%; }
+@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; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -1087,10 +1097,11 @@ 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-today-break-open] { grid-column:1 / -1; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
- .today-completion-undo { bottom:calc(224px + env(safe-area-inset-bottom)); }
+ .today-completion-undo { bottom:calc(272px + env(safe-area-inset-bottom)); }
}
@media (min-width:701px) { .update-gesture-status { display:none; } }
@media (prefers-reduced-motion: reduce) {
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index ff709d7..5cd31c6 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -976,6 +976,11 @@
getOwnerLogin: () => confirmedOwnerLogin,
mergeChecklistConflict: mergeChecklistConflict,
});
+ const todayProgress = createTodayProgress({
+ storage:localStorage,
+ getLogin:() => confirmedOwnerLogin,
+ admit:message => authoredOutbox.enqueueDurably(message),
+ });
const queueOfflineIssueBlocker = createOfflineIssueBlocker({
enqueueDurably: message => authoredOutbox.enqueueDurably(message),
});
@@ -1909,6 +1914,7 @@
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
!todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
+ todayProgressView.update();
updateDetailDeferLabels(active);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
@@ -1938,6 +1944,7 @@
document.querySelectorAll('[data-work-session-complete]').forEach(button => {
button.hidden = !workSession.checkpointed();
});
+ todayProgressView.update();
},
onFinish: () => {
updateWorkSessionActions();
@@ -1951,6 +1958,22 @@
todayRecapView.finish(selectedWorkFilter);
},
});
+ function currentTodayProgressTarget() {
+ if (!workSession.checkpointed()) return null;
+ const item = workSession.target('continue');
+ if (!item || item.is_review || !['issue', 'pull'].includes(item.kind) ||
+ !item.repository || !Number.isInteger(item.number)) return null;
+ return {
+ identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
+ number:item.number, label:item.key || item.repository + '#' + item.number,
+ title:item.title || '',
+ };
+ }
+ const todayProgressView = createTodayProgressView({
+ progress:todayProgress, currentTarget:currentTodayProgressTarget, qs,
+ announce:message => { qs('#my-work-action-status').textContent = message; },
+ onAdmitted:() => refreshMyWorkView(),
+ });
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
diff --git a/frontend/index.html b/frontend/index.html
index 45de51a..be790f4 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1536,9 +1536,24 @@
+
+
@@ -1655,6 +1670,7 @@
+
diff --git a/frontend/service-worker.js b/frontend/service-worker.js
index 62ab32e..eb6a4eb 100644
--- a/frontend/service-worker.js
+++ b/frontend/service-worker.js
@@ -52,6 +52,7 @@ const SHELL = [
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-break.js',
+ BASE + 'static/today-progress.js',
BASE + 'static/today-lock-screen.js',
BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js',
diff --git a/frontend/today-progress.js b/frontend/today-progress.js
new file mode 100644
index 0000000..00fe89d
--- /dev/null
+++ b/frontend/today-progress.js
@@ -0,0 +1,161 @@
+function createTodayProgress({ storage, getLogin = () => '', admit, makeId = () =>
+ globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
+maxLength = 2000, maxItems = 20 }) {
+ const prefix = 'stackchain.today-progress.v1.';
+ const login = () => String(getLogin() || '').trim().toLowerCase();
+ const storageKey = () => login() ? prefix + encodeURIComponent(login()) : '';
+ const validIdentity = identity => typeof identity === 'string' && identity.length > 0 && identity.length <= 500;
+ const validRecord = record => record && typeof record.body === 'string' && record.body.length > 0 &&
+ record.body.length <= maxLength && typeof record.operation_id === 'string' && record.operation_id.length > 0 &&
+ record.operation_id.length <= 128;
+
+ function read() {
+ const key = storageKey();
+ if (!storage || !key) return {};
+ try {
+ const saved = JSON.parse(storage.getItem(key) || 'null');
+ if (!saved || saved.version !== 1 || !saved.drafts || typeof saved.drafts !== 'object' ||
+ Array.isArray(saved.drafts)) return {};
+ const entries = Object.entries(saved.drafts).filter(([identity, record]) =>
+ validIdentity(identity) && validRecord(record)
+ ).slice(0, maxItems);
+ return Object.fromEntries(entries);
+ } catch (_error) {
+ return {};
+ }
+ }
+
+ function write(drafts) {
+ const key = storageKey();
+ if (!storage || !key) return false;
+ try {
+ if (Object.keys(drafts).length) storage.setItem(key, JSON.stringify({ version:1, drafts }));
+ else storage.removeItem(key);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+ }
+
+ function load(identity) {
+ if (!validIdentity(identity)) return '';
+ return read()[identity]?.body || '';
+ }
+
+ function save(identity, value) {
+ if (!validIdentity(identity) || !storageKey()) return false;
+ const body = String(value || '').trim();
+ if (body.length > maxLength) return false;
+ const drafts = read();
+ if (!body) {
+ delete drafts[identity];
+ return write(drafts);
+ }
+ if (!drafts[identity] && Object.keys(drafts).length >= maxItems) return false;
+ const previous = drafts[identity];
+ drafts[identity] = {
+ body,
+ operation_id: previous?.body === body ? previous.operation_id : String(makeId()).slice(0, 128),
+ };
+ return write(drafts);
+ }
+
+ function validTarget(target) {
+ return target && ['issue', 'pull'].includes(target.kind) && validIdentity(target.identity) &&
+ typeof target.repository === 'string' && target.repository.length > 0 && target.repository.length <= 200 &&
+ Number.isInteger(target.number) && target.number > 0;
+ }
+
+ async function post(target, value) {
+ if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
+ if (value !== undefined && !save(target.identity, value)) throw new Error('Progress update could not be saved on this device.');
+ const record = read()[target.identity];
+ if (!record) throw new Error('Write a progress update before posting.');
+ if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
+ const admission = await admit({
+ kind:target.kind + '-comment', repository:target.repository, number:target.number,
+ body:record.body, operationId:record.operation_id,
+ });
+ 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 };
+}
+
+function createTodayProgressView({ progress, currentTarget, qs, announce = () => {}, onAdmitted = () => {} }) {
+ const sheet = qs('#today-progress-sheet');
+ const body = qs('#today-progress-body');
+ const status = qs('#today-progress-status');
+ const launcher = qs('[data-mobile-today-update]');
+ let openedTarget = null;
+
+ const update = () => { launcher.hidden = !currentTarget(); };
+ const checkpoint = () => {
+ if (!openedTarget) return false;
+ if (progress.save(openedTarget.identity, body.value)) return true;
+ status.textContent = 'Update must be 2,000 characters or fewer and device storage must be available.';
+ return false;
+ };
+ const close = () => {
+ if (sheet.open) sheet.close();
+ openedTarget = null;
+ };
+
+ launcher.addEventListener('click', () => {
+ const target = currentTarget();
+ if (!target) return;
+ openedTarget = target;
+ qs('#today-progress-target').textContent = target.label + (target.title ? ' · ' + target.title : '');
+ body.value = progress.load(target.identity);
+ status.textContent = '';
+ sheet.showModal();
+ body.focus();
+ });
+ qs('#cancel-today-progress').addEventListener('click', () => {
+ if (checkpoint()) close();
+ });
+ sheet.addEventListener('cancel', event => {
+ event.preventDefault();
+ if (checkpoint()) close();
+ });
+ qs('#save-today-progress').addEventListener('click', () => {
+ if (!checkpoint()) return;
+ announce('Progress update saved privately to this Today item.');
+ close();
+ });
+ qs('#post-today-progress').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 button = qs('#post-today-progress');
+ button.disabled = true;
+ status.textContent = 'Saving for delivery…';
+ try {
+ const admission = await progress.post(target, body.value);
+ onAdmitted(admission);
+ announce(admission.background ?
+ 'Progress update queued for delivery. Today is still on the same item.' :
+ 'Progress update saved for next launch. Today is still on the same item.');
+ close();
+ } catch (error) {
+ status.textContent = error.message + ' Your update remains on this item; retry.';
+ body.focus();
+ } finally {
+ button.disabled = false;
+ }
+ });
+ update();
+ return { update };
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = createTodayProgress;
+ module.exports.createView = createTodayProgressView;
+}
diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py
index 05a9d76..00e0460 100644
--- a/src/frontend_bundle.py
+++ b/src/frontend_bundle.py
@@ -34,7 +34,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"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-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.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/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",
diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py
index fd1fa38..dae44be 100644
--- a/tests/test_service_worker.py
+++ b/tests/test_service_worker.py
@@ -1153,6 +1153,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-break.js",
+ "/dashboard/static/today-progress.js",
"/dashboard/static/today-lock-screen.js",
"/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js",
diff --git a/tests/test_today_progress.py b/tests/test_today_progress.py
new file mode 100644
index 0000000..54e3117
--- /dev/null
+++ b/tests/test_today_progress.py
@@ -0,0 +1,128 @@
+import json
+import subprocess
+from pathlib import Path
+
+
+ROOT = Path(__file__).parents[1]
+TODAY_PROGRESS = ROOT / "frontend" / "today-progress.js"
+
+
+def run_node(script: str) -> dict:
+ result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+ assert result.returncode == 0, result.stderr
+ return json.loads(result.stdout)
+
+
+def test_progress_drafts_are_account_and_item_scoped_bounded_and_durable():
+ script = f"""
+const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+let login = 'Timmy';
+const progress = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
+const issue = {{identity:'issue:stackchain/dashboard:12:', kind:'issue', repository:'stackchain/dashboard', number:12}};
+const pull = {{identity:'pull:stackchain/dashboard:13:', kind:'pull', repository:'stackchain/dashboard', number:13}};
+const saved = progress.save(issue.identity, ' Investigated latency ');
+progress.save(pull.identity, 'Reviewing tests');
+const restored = createProgress({{storage, getLogin:() => login, admit:async () => ({{}}), maxLength:20}});
+login = 'alexander';
+const otherAccount = restored.load(issue.identity);
+login = 'timmy';
+process.stdout.write(JSON.stringify({{
+ saved,
+ issue:restored.load(issue.identity),
+ pull:restored.load(pull.identity),
+ otherAccount,
+ tooLong:restored.save(issue.identity, 'x'.repeat(21)),
+ keys:[...values.keys()],
+}}));
+"""
+ output = run_node(script)
+ assert output["saved"] is True
+ assert output["issue"] == "Investigated latency"
+ assert output["pull"] == "Reviewing tests"
+ assert output["otherAccount"] == ""
+ assert output["tooLong"] is False
+ assert output["keys"] == ["stackchain.today-progress.v1.timmy"]
+
+
+def test_post_admits_exact_comment_once_and_clears_only_after_durable_admission():
+ 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 calls=[]; let fail=true;
+const progress=createProgress({{
+ storage, getLogin:()=> 'timmy', makeId:()=> 'progress-op-1',
+ admit:async message => {{ calls.push(message); if (fail) throw new Error('offline store unavailable'); return {{background:true,item:{{id:message.operationId}}}}; }},
+}});
+const target={{identity:'issue:stackchain/dashboard:12:',kind:'issue',repository:'stackchain/dashboard',number:12}};
+progress.save(target.identity, 'Shipped the first slice');
+let error='';
+try {{ await progress.post(target); }} catch (caught) {{ error=caught.message; }}
+const retained=progress.load(target.identity);
+fail=false;
+const admitted=await progress.post(target);
+process.stdout.write(JSON.stringify({{error,retained,after:progress.load(target.identity),calls,admitted}}));
+"""
+ output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
+ assert output["error"] == "offline store unavailable"
+ assert output["retained"] == "Shipped the first slice"
+ assert output["after"] == ""
+ assert len(output["calls"]) == 2
+ assert output["calls"][0] == output["calls"][1] == {
+ "kind": "issue-comment",
+ "repository": "stackchain/dashboard",
+ "number": 12,
+ "body": "Shipped the first slice",
+ "operationId": "progress-op-1",
+ }
+ assert output["admitted"]["background"] is True
+
+
+def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
+ script = f"""
+const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
+const values=new Map(); let admitted=0;
+const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
+const progress=createProgress({{storage,getLogin:()=> 'timmy',admit:async()=>{{admitted++;}}}});
+const results=[];
+for (const target of [null, {{identity:'review:r:1:',kind:'review',repository:'r',number:1}}, {{identity:'issue:r:0:',kind:'issue',repository:'r',number:0}}]) {{
+ try {{ await progress.post(target, 'note'); results.push('accepted'); }} catch (error) {{ results.push(error.message); }}
+}}
+process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}));
+"""
+ output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
+ assert output["admitted"] == 0
+ assert output["keys"] == []
+ assert output["results"] == [
+ "An active Today issue or pull request is required.",
+ "An active Today issue or pull request is required.",
+ "An active Today issue or pull request is required.",
+ ]
+
+
+def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
+ html = (ROOT / "frontend" / "index.html").read_text()
+ css = (ROOT / "frontend" / "dashboard.css").read_text()
+ dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
+ bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
+
+ assert 'data-mobile-today-update' in html
+ assert 'id="today-progress-sheet"' in html
+ assert 'aria-labelledby="today-progress-title"' in html
+ assert 'id="today-progress-body"' in html
+ assert 'maxlength="2000"' in html
+ assert 'id="save-today-progress"' in html
+ assert 'id="post-today-progress"' in html
+ assert '' in html
+ assert '"static/today-progress.js"' in bundle
+ assert "workSession.target('continue')" in dashboard
+ assert "authoredOutbox.enqueueDurably" in dashboard
+ assert ".today-progress-panel" in css
+ assert "env(safe-area-inset-bottom)" in css
+ assert ".today-progress-actions button" in css and "min-height:44px" in css