Review recent activity before posting a Today update #1061
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user