feat: review activity before Today updates (Closes #1060)
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 1m53s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 04:00:58 +00:00
parent e290293bf1
commit 47626338ba
5 changed files with 228 additions and 4 deletions

View File

@ -276,6 +276,15 @@ textarea { resize: vertical; min-height: 120px; }
.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-activity { display:grid; gap:8px; min-width:0; margin:0 0 14px; padding:12px; border:1px solid #294767; border-radius:12px; background:#0d1b2d; }
.today-progress-activity-heading { display:flex; justify-content:space-between; gap:8px; }
.today-progress-activity-list { display:grid; gap:8px; max-height:32dvh; overflow:auto; overflow-x:hidden; overflow-wrap:anywhere; }
.today-progress-activity-list:empty::before { content:'No recent messages yet.'; color:#9ca3af; font-size:.875rem; }
.today-progress-activity-item { min-width:0; padding:8px; border-radius:8px; background:#101f34; }
.today-progress-activity-item .markdown-content { overflow-wrap:anywhere; }
.today-progress-activity-actions { display:flex; gap:8px; }
.today-progress-activity-actions button { min-height:44px; }
.today-progress-activity-status { margin:0; }
.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; }

View File

@ -1968,6 +1968,7 @@
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice,
activity:mountTodayProgressActivity(qs, fetchReviewJson),
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
});

View File

@ -1544,6 +1544,18 @@
<section class="today-progress-panel">
<header><div><p class="small muted">Active Today item</p><h2 id="today-progress-title">Add progress update</h2></div><button id="cancel-today-progress" type="button">Cancel</button></header>
<p id="today-progress-target" class="small"></p>
<section class="today-progress-activity" id="today-progress-activity" aria-labelledby="today-progress-activity-title">
<div class="today-progress-activity-heading">
<strong id="today-progress-activity-title">Recent activity</strong>
<span id="today-progress-activity-count" class="small muted"></span>
</div>
<div id="today-progress-activity-list" class="today-progress-activity-list" aria-live="polite"></div>
<p id="today-progress-activity-status" class="small" role="status" aria-live="polite"></p>
<div class="today-progress-activity-actions">
<button id="retry-today-progress-activity" type="button" hidden>Retry activity</button>
<button id="load-older-today-progress-activity" type="button" hidden>Load older</button>
</div>
</section>
<label for="today-progress-body">Update</label>
<textarea id="today-progress-body" rows="5" maxlength="2000" placeholder="What changed, what you learned, or what comes next"></textarea>
<section class="voice-conversation" id="voice-today-progress" data-draft-label="progress update" aria-label="Dictate Today progress update" hidden>

View File

@ -132,7 +132,96 @@ maxLength = 2000, maxItems = 20 }) {
return { load, save, discard:identity => save(identity, ''), post };
}
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, announce = () => {}, onAdmitted = () => {} }) {
function createTodayProgressActivity({ fetchJson, createPager, paint = () => {}, setStatus = () => {} }) {
let requestToken = 0;
let pager = null;
let target = null;
const pathFor = (value, page) => {
const repository = String(value.repository || '').split('/').map(encodeURIComponent).join('/');
const resource = value.kind === 'pull' ? 'pulls' : 'issues';
return 'api/v1/repos/' + repository + '/' + resource + '/' + encodeURIComponent(value.number) +
'/comments?' + (Number.isInteger(page) ? 'page=' + encodeURIComponent(page) + '&' : '') + 'limit=20';
};
const loadPage = page => fetchJson(pathFor(target, page));
async function open(value) {
const token = ++requestToken;
target = { ...value };
setStatus('Loading recent activity…');
try {
const page = await loadPage();
if (token !== requestToken || target.identity !== value.identity) return false;
pager = createPager({ loadPage });
paint(pager.reset(page));
setStatus('');
return true;
} catch (error) {
if (token === requestToken) setStatus('Recent activity unavailable. Retry.');
return false;
}
}
async function loadOlder() {
if (!pager || !target) return false;
const token = requestToken;
setStatus('Loading older activity…');
try {
const page = await pager.loadOlder();
if (token !== requestToken) return false;
paint(page);
setStatus('');
return true;
} catch (_error) {
if (token === requestToken) setStatus('Older activity unavailable. Retry.');
return false;
}
}
return {
open,
retry:() => target ? open(target) : Promise.resolve(false),
loadOlder,
close:() => { requestToken++; target = null; pager = null; setStatus(''); },
};
}
function mountTodayProgressActivity(qs, fetchJson) {
const activity = createTodayProgressActivity({
fetchJson,
createPager:createConversationPager,
paint:state => {
const list = qs('#today-progress-activity-list');
list.innerHTML = (state.comments || []).map(comment => {
const author = comment.author || comment.user?.login || 'Unknown author';
const timing = comment.created_at ? ' · ' + new Date(comment.created_at).toLocaleString() : '';
return '<article class="today-progress-activity-item"><div class="small muted">' +
escapeHtml(author) + escapeHtml(timing) + '</div><div class="markdown-content">' +
renderMarkdown(comment.body || 'No message body provided.') + '</div></article>';
}).join('');
qs('#today-progress-activity-count').textContent = state.total ?
String(state.comments.length) + ' of ' + String(state.total) + ' messages' : '';
qs('#load-older-today-progress-activity').hidden = !Number.isInteger(state.older_page);
},
setStatus:message => {
qs('#today-progress-activity-status').textContent = message;
qs('#retry-today-progress-activity').hidden = !message.includes('unavailable');
},
});
qs('#retry-today-progress-activity').addEventListener('click', () => activity.retry());
qs('#load-older-today-progress-activity').addEventListener('click', async () => {
const list = qs('#today-progress-activity-list');
const previousHeight = list.scrollHeight;
const button = qs('#load-older-today-progress-activity');
button.disabled = true;
await activity.loadOlder();
list.scrollTop += list.scrollHeight - previousHeight;
button.disabled = false;
});
return activity;
}
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, announce = () => {}, onAdmitted = () => {} }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
@ -155,6 +244,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
};
const close = () => {
voice?.cancel?.();
activity?.close?.();
if (sheet.open) sheet.close();
openedTarget = null;
};
@ -170,6 +260,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
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.'; }
@ -276,4 +367,5 @@ if (typeof module !== 'undefined' && module.exports) {
module.exports = createTodayProgress;
module.exports.createView = createTodayProgressView;
module.exports.createPhotos = createTodayProgressPhotos;
module.exports.createActivity = createTodayProgressActivity;
}

View File

@ -216,8 +216,9 @@ const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element(
const target={{identity:'issue:stackchain/dashboard:1058:',kind:'issue',repository:'stackchain/dashboard',number:1058,label:'#1058',title:'Voice progress'}};
const calls=[];
const voice={{open:async identity=>calls.push(['open',identity]),cancel:()=>calls.push(['cancel'])}};
const activity={{open:async value=>calls.push(['activity-open',value.identity]),close:()=>calls.push(['activity-close'])}};
const view=createView({{
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,activity,
photos:{{open:async()=>calls.push(['photos'])}},
}});
(async()=>{{
@ -231,13 +232,112 @@ const view=createView({{
"opened": {
"body": "Saved text",
"sheet": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"]],
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["activity-open", "issue:stackchain/dashboard:1058:"]],
},
"closed": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["cancel"]],
"calls": [
["open", "issue:stackchain/dashboard:1058:"],
["photos"],
["activity-open", "issue:stackchain/dashboard:1058:"],
["cancel"],
["activity-close"],
],
}
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const page={{comments:[{{id:2,body:'Latest',user:{{login:'alex'}},created_at:'2026-08-18T01:00:00Z'}}],page:3,older_page:2,total:41}};
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);return page;}},
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:()=>loadPage(2)}}),
paint:value=>paints.push(value), setStatus:value=>calls.push('status:'+value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);process.stdout.write(JSON.stringify({{calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["calls"] == [
"status:Loading recent activity…",
"api/v1/repos/stackchain/dashboard/issues/1060/comments?limit=20",
"status:",
]
assert output["paints"] == [{
"comments": [{
"id": 2,
"body": "Latest",
"user": {"login": "alex"},
"created_at": "2026-08-18T01:00:00Z",
}],
"page": 3,
"older_page": 2,
"total": 41,
}]
def test_recent_activity_rejects_a_slow_response_after_close():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
let resolve; const paints=[]; const statuses=[];
const activity=createActivity({{
fetchJson:()=>new Promise(done=>{{resolve=done;}}),
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value), setStatus:value=>statuses.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const pending=activity.open(target);activity.close();resolve({{comments:[{{id:1}}]}});const result=await pending;process.stdout.write(JSON.stringify({{result,paints,statuses}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"result": False,
"paints": [],
"statuses": ["Loading recent activity…", ""],
}
def test_recent_activity_retry_recovers_the_same_pull_after_failure():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[]; let fail=true;
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);if(fail)throw new Error('offline');return {{comments:[],page:1,older_page:null,total:0}};}},
createPager:()=>({{reset:value=>value}}), paint:value=>paints.push(value),
}});
const target={{identity:'pull:stackchain/dashboard:9:',kind:'pull',repository:'stackchain/dashboard',number:9}};
(async()=>{{const opened=await activity.open(target);fail=false;const retried=await activity.retry();process.stdout.write(JSON.stringify({{opened,retried,calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["opened"] is False
assert output["retried"] is True
assert output["calls"] == [
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
"api/v1/repos/stackchain/dashboard/pulls/9/comments?limit=20",
]
assert output["paints"] == [{"comments": [], "page": 1, "older_page": None, "total": 0}]
def test_recent_activity_load_older_paints_the_deduplicated_page():
script = f"""
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
const calls=[]; const paints=[];
const activity=createActivity({{
fetchJson:async path=>{{calls.push(path);return {{comments:[],page:2,older_page:null,total:20}};}},
createPager:({{loadPage}})=>({{reset:value=>value,loadOlder:async()=>{{await loadPage(2);return {{comments:[{{id:1}}],page:2,older_page:null,total:21}};}}}}),
paint:value=>paints.push(value),
}});
const target={{identity:'issue:stackchain/dashboard:1060:',kind:'issue',repository:'stackchain/dashboard',number:1060}};
(async()=>{{await activity.open(target);const loaded=await activity.loadOlder();process.stdout.write(JSON.stringify({{loaded,calls,paints}}));}})()
.catch(error=>{{console.error(error);process.exit(1);}});
"""
output = run_node(script)
assert output["loaded"] is True
assert output["calls"][-1] == "api/v1/repos/stackchain/dashboard/issues/1060/comments?page=2&limit=20"
assert output["paints"][-1]["comments"] == [{"id": 1}]
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()
@ -248,6 +348,11 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
assert 'id="today-progress-sheet"' in html
assert 'aria-labelledby="today-progress-title"' in html
assert 'id="today-progress-body"' in html
assert 'id="today-progress-activity"' in html
assert 'id="today-progress-activity-list"' in html
assert 'id="today-progress-activity-status"' in html
assert 'id="retry-today-progress-activity"' in html
assert 'id="load-older-today-progress-activity"' in html
for control in (
"voice-today-progress",
"start-voice-today-progress",
@ -278,6 +383,11 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
assert "photos:todayProgressPhotos" in dashboard
assert "['today-progress', '#today-progress-body']" in dashboard
assert "voice:todayProgressVoice" in dashboard
assert "mountTodayProgressActivity" in dashboard
assert "activity:mountTodayProgressActivity" in dashboard
assert ".today-progress-activity-list" in css
assert ".today-progress-activity-actions button" in css
assert "min-height:44px" in css
assert "const controller = issueAttachment.mount" in TODAY_PROGRESS.read_text()
assert "const checkpointAttachments = await photos?.serialize?.() || [];" in TODAY_PROGRESS.read_text()
assert "progress.save(openedTarget.identity, body.value, checkpointAttachments)" in TODAY_PROGRESS.read_text()