diff --git a/frontend/dashboard.css b/frontend/dashboard.css index f512ef5..f966707 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,6 +1097,7 @@ 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; } 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 @@
+ + +
+

Active Today item

Add progress update

+

+ + +

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

+

+
+ + +
+
+
@@ -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