Merge pull request 'Dictate and review active Today progress updates' (#1059) from timmy/1058-today-progress-voice-dictation into main
All checks were successful
CI / lint (push) Successful in 2m44s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 2m4s
CI / release-candidate (push) Successful in 7s

This commit is contained in:
timmy 2026-08-18 03:11:36 +00:00
commit e290293bf1
8 changed files with 218 additions and 31 deletions

View File

@ -122,6 +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.
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

View File

@ -281,6 +281,9 @@ textarea { resize: vertical; min-height: 120px; }
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
.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; }
.today-progress-panel .voice-conversation-controls button,
.today-progress-panel .voice-conversation-review-actions button { min-height:44px; }
@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; }

View File

@ -839,24 +839,15 @@
getLogin:() => confirmedOwnerLogin,
});
const voiceTranscriptStore = createVoiceTranscriptStore();
function mountConversationVoice(kind, draftSelector) {
return createVoiceConversationCapture({
Recognition: window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore: voiceTranscriptStore,
getLogin: () => confirmedOwnerLogin,
elements: {
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
const issueVoiceReply = mountConversationVoice('issue-comment', '#issue-comment');
const pullVoiceReply = mountConversationVoice('pull-comment', '#pull-comment');
const updateVoiceReply = mountConversationVoice('update-reply', '#update-reply');
const searchVoiceReply = mountConversationVoice('search-reply', '#search-preview-reply');
const voiceOptions = {
qs, Recognition:window.SpeechRecognition || window.webkitSpeechRecognition,
transcriptStore:voiceTranscriptStore, getLogin:() => confirmedOwnerLogin,
};
const [issueVoiceReply, pullVoiceReply, updateVoiceReply, searchVoiceReply, todayProgressVoice] = [
['issue-comment', '#issue-comment'], ['pull-comment', '#pull-comment'],
['update-reply', '#update-reply'], ['search-reply', '#search-preview-reply'],
['today-progress', '#today-progress-body'],
].map(([kind, draftSelector]) => mountVoiceConversation({...voiceOptions, kind, draftSelector}));
function conversationVoiceTarget(kind, item) {
return kind + ':' + String(item?.repository || '').trim().toLowerCase() + '#' + Number(item?.number);
}
@ -1976,6 +1967,7 @@
}
const todayProgressView = createTodayProgressView({
progress:todayProgress, currentTarget:currentTodayProgressTarget, qs, photos:todayProgressPhotos,
voice:todayProgressVoice,
announce:message => { qs('#my-work-action-status').textContent = message; },
onAdmitted:() => refreshMyWorkView(),
});

View File

@ -1546,6 +1546,22 @@
<p id="today-progress-target" class="small"></p>
<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>
<div class="voice-conversation-controls">
<button id="start-voice-today-progress" type="button">Dictate update</button>
<button id="stop-voice-today-progress" type="button" hidden>Stop listening</button>
</div>
<div id="voice-today-progress-status" class="small" aria-live="polite"></div>
<div class="voice-conversation-review" id="voice-today-progress-review" hidden>
<label for="voice-today-progress-transcript">Review transcript</label>
<textarea id="voice-today-progress-transcript" maxlength="2000"></textarea>
<div class="voice-conversation-review-actions">
<button id="append-voice-today-progress" type="button">Use transcript</button>
<button id="replace-with-voice-today-progress" type="button" hidden>Replace update</button>
<button id="discard-voice-today-progress" type="button">Discard</button>
</div>
</div>
</section>
<section class="today-progress-evidence" aria-labelledby="today-progress-evidence-label">
<strong id="today-progress-evidence-label">Photo evidence <span class="small">Optional · Up to 5</span></strong>
<div class="issue-attachment-controls conversation-photo-actions">

View File

@ -132,7 +132,7 @@ maxLength = 2000, maxItems = 20 }) {
return { load, save, discard:identity => save(identity, ''), post };
}
function createTodayProgressView({ progress, currentTarget, qs, photos, announce = () => {}, onAdmitted = () => {} }) {
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, announce = () => {}, onAdmitted = () => {} }) {
const sheet = qs('#today-progress-sheet');
const body = qs('#today-progress-body');
const status = qs('#today-progress-status');
@ -154,6 +154,7 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, announce
}
};
const close = () => {
voice?.cancel?.();
if (sheet.open) sheet.close();
openedTarget = null;
};
@ -166,7 +167,11 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, announce
body.value = progress.load(target.identity);
status.textContent = 'Restoring saved photo evidence…';
sheet.showModal();
try { await photos?.open?.(target); status.textContent = ''; }
try {
await voice?.open?.(target.identity);
await photos?.open?.(target);
status.textContent = '';
}
catch (error) { status.textContent = error.message + ' You can retry by reopening this update.'; }
body.focus();
});

View File

@ -1,21 +1,26 @@
function createVoiceConversationCapture({
Recognition, elements, transcriptStore = null, getLogin = () => '',
createEvent = name => new Event(name, {bubbles:true}),
maxLength = 0, draftLabel = '',
}) {
const supported = typeof Recognition === 'function';
const label = String(draftLabel || elements.root.dataset?.draftLabel || 'reply').trim() || 'reply';
let recognition = null;
let target = '';
let generation = 0;
elements.root.hidden = !supported;
if (!supported) {
elements.status.textContent = 'Voice capture is unavailable; type the reply instead.';
elements.status.textContent = 'Voice capture is unavailable; type the ' + label + ' instead.';
}
const normalize = value => String(value || '').replace(/\s+/g, ' ').trim();
const login = () => String(getLogin() || '').trim();
const composerLimit = Number(elements.draft.maxLength);
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength :
Number.isInteger(composerLimit) && composerLimit > 0 ? composerLimit : 10000;
function showReview(value, recovered = false) {
const transcript = normalize(value).slice(0, 10000);
const transcript = normalize(value).slice(0, limit);
elements.transcript.value = transcript;
elements.review.hidden = false;
elements.append.textContent = elements.draft.value.trim() ? 'Append to draft' : 'Use transcript';
@ -36,7 +41,7 @@ function createVoiceConversationCapture({
elements.review.hidden = true;
elements.status.textContent = '';
if (!supported || !target || !transcriptStore || !login()) return;
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, 10000);
const recovered = normalize(await transcriptStore.load(login(), target)).slice(0, limit);
if (opening === generation && recovered) showReview(recovered, true);
}
@ -45,13 +50,13 @@ function createVoiceConversationCapture({
}
function commit(replace) {
const transcript = normalize(elements.transcript.value).slice(0, 10000);
const transcript = normalize(elements.transcript.value).slice(0, limit);
elements.draft.value = replace ? transcript :
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, 10000);
[elements.draft.value.trim(), transcript].filter(Boolean).join('\n\n').slice(0, limit);
elements.draft.dispatchEvent(createEvent('input'));
clearCheckpoint();
elements.review.hidden = true;
elements.status.textContent = 'Transcript added. Review the reply before sending.';
elements.status.textContent = 'Transcript added. Review the ' + label + ' before sending.';
}
elements.start.addEventListener('click', () => {
@ -121,4 +126,20 @@ function createVoiceConversationCapture({
return {supported, open, cancel};
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture};
function mountVoiceConversation({
kind, draftSelector, qs, Recognition, transcriptStore, getLogin,
createCapture = createVoiceConversationCapture,
}) {
return createCapture({
Recognition, transcriptStore, getLogin,
elements:{
root:qs('#voice-' + kind), start:qs('#start-voice-' + kind),
stop:qs('#stop-voice-' + kind), review:qs('#voice-' + kind + '-review'),
transcript:qs('#voice-' + kind + '-transcript'), append:qs('#append-voice-' + kind),
replace:qs('#replace-with-voice-' + kind), discard:qs('#discard-voice-' + kind),
status:qs('#voice-' + kind + '-status'), draft:qs(draftSelector),
},
});
}
if (typeof module !== 'undefined') module.exports = {createVoiceConversationCapture, mountVoiceConversation};

View File

@ -201,6 +201,43 @@ process.stdout.write(JSON.stringify({{results,admitted,keys:[...values.keys()]}}
]
def test_progress_sheet_scopes_voice_to_the_open_item_and_aborts_on_close():
script = f"""
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=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'];
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 view=createView({{
progress:{{load:()=> 'Saved text',save:()=>true}},currentTarget:()=>target,qs:selector=>elements[selector],voice,
photos:{{open:async()=>calls.push(['photos'])}},
}});
(async()=>{{
await elements['[data-mobile-today-update]'].click();
const opened={{body:elements['#today-progress-body'].value,sheet:elements['#today-progress-sheet'].open,calls:[...calls]}};
await elements['#cancel-today-progress'].click();
process.stdout.write(JSON.stringify({{opened,closed:!elements['#today-progress-sheet'].open,calls}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"opened": {
"body": "Saved text",
"sheet": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"]],
},
"closed": True,
"calls": [["open", "issue:stackchain/dashboard:1058:"], ["photos"], ["cancel"]],
}
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()
@ -211,6 +248,20 @@ 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
for control in (
"voice-today-progress",
"start-voice-today-progress",
"stop-voice-today-progress",
"voice-today-progress-review",
"voice-today-progress-transcript",
"append-voice-today-progress",
"replace-with-voice-today-progress",
"discard-voice-today-progress",
"voice-today-progress-status",
):
assert f'id="{control}"' in html
assert 'for="voice-today-progress-transcript"' in html
assert 'data-draft-label="progress update"' in html
assert 'id="take-today-progress-photo"' in html
assert 'id="today-progress-attachment"' in html
assert 'id="today-progress-attachment-preview"' in html
@ -225,6 +276,8 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
assert "scope:'today-progress'" in TODAY_PROGRESS.read_text()
assert "lanes:{ today:" in TODAY_PROGRESS.read_text()
assert "photos:todayProgressPhotos" in dashboard
assert "['today-progress', '#today-progress-body']" in dashboard
assert "voice:todayProgressVoice" in dashboard
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()
@ -232,3 +285,4 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
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
assert ".today-progress-panel .voice-conversation-controls button" in css

View File

@ -78,6 +78,101 @@ const store={{
}
def test_today_progress_dictation_uses_its_composer_boundary_and_copy():
script = f"""
const {{mountVoiceConversation}} = require({json.dumps(str(VOICE_CAPTURE))});
const requested=[]; const nodes=new Map();
const qs=selector=>{{requested.push(selector);if(!nodes.has(selector))nodes.set(selector,{{selector}});return nodes.get(selector);}};
const created=[];
const result=mountVoiceConversation({{
kind:'today-progress',draftSelector:'#today-progress-body',qs,Recognition:function Recognition(){{}},
transcriptStore:{{name:'store'}},getLogin:()=> 'timmy',
createCapture:options=>{{created.push(options);return {{supported:true}};}},
}});
process.stdout.write(JSON.stringify({{result,requested,elements:Object.fromEntries(Object.entries(created[0].elements).map(([key,value])=>[key,value.selector]))}}));
"""
assert run_node(script) == {
"result": {"supported": True},
"requested": [
"#voice-today-progress", "#start-voice-today-progress", "#stop-voice-today-progress",
"#voice-today-progress-review", "#voice-today-progress-transcript",
"#append-voice-today-progress", "#replace-with-voice-today-progress",
"#discard-voice-today-progress", "#voice-today-progress-status", "#today-progress-body",
],
"elements": {
"root": "#voice-today-progress", "start": "#start-voice-today-progress",
"stop": "#stop-voice-today-progress", "review": "#voice-today-progress-review",
"transcript": "#voice-today-progress-transcript", "append": "#append-voice-today-progress",
"replace": "#replace-with-voice-today-progress", "discard": "#discard-voice-today-progress",
"status": "#voice-today-progress-status", "draft": "#today-progress-body",
},
}
script = f"""
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}};this.events=[]; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
click() {{ this.listeners.click?.(); }} dispatchEvent(event) {{ this.events.push(event.type); }}
}}
let recognition;
class Recognition {{ constructor() {{ recognition=this; }} start() {{}} stop() {{}} abort() {{}} }}
const elements=Object.fromEntries(
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
);
elements.draft.value='Typed';
elements.draft.maxLength=2000;
elements.root.dataset={{draftLabel:'progress update'}};
const saved=[];
const store={{load:async()=>'',save:async(_login,text)=>saved.push(text),clear:async()=>{{}}}};
(async()=>{{
const controller=createVoiceConversationCapture({{
Recognition,elements,transcriptStore:store,getLogin:()=> 'timmy',createEvent:name=>({{type:name}})
}});
await controller.open('issue:stackchain/dashboard:1058:');
elements.start.click();
recognition.onresult({{results:[Object.assign([{{transcript:'x'.repeat(2500)}}],{{isFinal:true}})]}});
const reviewed={{length:elements.transcript.value.length,status:elements.status.textContent,savedLength:saved[0].length}};
elements.replace.click();
process.stdout.write(JSON.stringify({{reviewed,draftLength:elements.draft.value.length,status:elements.status.textContent}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert run_node(script) == {
"reviewed": {
"length": 2000,
"savedLength": 2000,
"status": "Transcript ready. Review it before using it.",
},
"draftLength": 2000,
"status": "Transcript added. Review the progress update before sending.",
}
def test_unsupported_today_dictation_keeps_progress_entry_available_with_truthful_copy():
script = f"""
const {{createVoiceConversationCapture}} = require({json.dumps(str(VOICE_CAPTURE))});
class Element {{
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.listeners={{}}; }}
addEventListener(name,callback) {{ this.listeners[name]=callback; }} dispatchEvent() {{}}
}}
const elements=Object.fromEntries(
['root','start','stop','review','transcript','append','replace','discard','status','draft'].map(key=>[key,new Element()])
);
const controller=createVoiceConversationCapture({{Recognition:null,elements,draftLabel:'progress update'}});
process.stdout.write(JSON.stringify({{
supported:controller.supported,voiceHidden:elements.root.hidden,draftHidden:elements.draft.hidden,
status:elements.status.textContent
}}));
"""
assert run_node(script) == {
"supported": False,
"voiceHidden": True,
"draftHidden": False,
"status": "Voice capture is unavailable; type the progress update instead.",
}
def test_private_store_isolates_conversation_transcripts_by_account_and_target():
script = f"""
const createVoiceTranscriptStore=require({json.dumps(str(VOICE_STORE))});
@ -173,9 +268,9 @@ def test_all_mobile_conversation_composers_ship_accessible_voice_review_controls
assert f'id="replace-with-voice-{kind}"' in html
assert f'id="discard-voice-{kind}"' in html
assert "mountConversationVoice('issue-comment', '#issue-comment')" in dashboard
assert "mountConversationVoice('pull-comment', '#pull-comment')" in dashboard
assert "mountConversationVoice('update-reply', '#update-reply')" in dashboard
assert "['issue-comment', '#issue-comment']" in dashboard
assert "['pull-comment', '#pull-comment']" in dashboard
assert "['update-reply', '#update-reply']" in dashboard
assert ".voice-conversation-controls button" in css
assert "min-height:44px" in css
assert '"static/voice-conversation-capture.js"' in bundle
@ -206,7 +301,7 @@ def test_mobile_search_reply_ships_target_scoped_voice_review_and_cancels_on_clo
assert f'id="{control}"' in html
assert 'for="voice-search-reply-transcript"' in html
assert "mountConversationVoice('search-reply', '#search-preview-reply')" in dashboard
assert "['search-reply', '#search-preview-reply']" in dashboard
assert "searchVoiceReply.open(searchConversationVoiceTarget(state.item))" in dashboard
assert "searchVoiceReply.cancel();" in dashboard
assert ".search-preview-reply .voice-conversation-controls button" in css