feat: turn Today recap feedback into a plan
All checks were successful
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped

Closes #583
This commit is contained in:
timmy 2026-08-11 18:33:59 +00:00
parent be7bfcaa5a
commit 1fb26e0a86
8 changed files with 217 additions and 28 deletions

View File

@ -67,7 +67,7 @@ and an active Today session shows the current estimate plus estimated remaining
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. 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. 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 resulting 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.
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. 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. Actual time appears there as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. 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.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it

View File

@ -147,10 +147,17 @@ textarea { resize: vertical; min-height: 120px; }
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
.today-recap-row strong { overflow-wrap:anywhere; }
.today-recap-row input { width:6rem; min-height:44px; }
.today-recap-context { display:block; overflow-wrap:anywhere; }
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
@media (max-width:420px) { .today-recap-actions { grid-template-columns:1fr; } }
@media (max-width:420px) {
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
.today-recap-variance { grid-column:1; }
}
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
.today-readiness-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 #b45309; border-radius:18px 18px 0 0; background:#0b1526; }

View File

@ -937,7 +937,9 @@
});
const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin
() => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => openPlanToday(qs('#plan-today'), true, actualMinutes)
);
function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
@ -1280,13 +1282,16 @@
const title = escapeHtml(item.title || 'Untitled work');
const state = planToday.snapshot();
const estimate = state.estimates?.[id] || '';
const recommendation = state.recommendations?.[id];
const estimateControl = selected ? '<label class="small plan-today-estimate-wrap">Estimate <input class="plan-today-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" value="' + escAttr(estimate) + '" data-plan-estimate="' + escAttr(id) + '" aria-label="Estimate for ' + escAttr(item.title || key) + ' in minutes" /> min</label>' : '';
const recommendationControl = selected && recommendation ? '<button class="plan-today-recommendation" type="button" data-plan-recommendation="' +
escAttr(id) + '">Use ' + formatPlanMinutes(recommendation) + ' as new estimate</button>' : '';
const controls = selected ?
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === state.count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
(item.kind === 'issue' ?
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview to add</button></div>' :
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>');
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + '</div>' + controls + '</article>';
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong>' + estimateControl + recommendationControl + '</div>' + controls + '</article>';
}
function resetPlanTodayConfirmation() {
@ -1343,6 +1348,11 @@
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
document.querySelector('[data-plan-estimate="' + CSS.escape(button.dataset.planRecommendation) + '"]')?.focus();
}));
}
function closePlanToday(navigate = true) {
@ -1618,17 +1628,21 @@
taskOverlayHistory.close();
});
function openPlanToday(trigger, navigate = true) {
let pendingPlanActualMinutes = null;
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
if (trigger) planTodayTrigger = trigger;
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
if (navigate) {
taskOverlayHistory.open('plan-today');
return;
}
planToday.open(todayMyWork, activeMyWork, todayWork.planning());
const recommendations = actualMinutes || pendingPlanActualMinutes;
pendingPlanActualMinutes = null;
planToday.open(todayMyWork, activeMyWork, todayWork.planning(), recommendations);
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
document.body.classList.add('task-overlay-open');

View File

@ -281,7 +281,7 @@
<div id="today-recap-history" class="today-recap-history"></div>
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap</button>
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>

View File

@ -4,6 +4,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
function cleanItems(items) {
@ -15,7 +17,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return unique;
}
function open(selectedItems, candidates, planning = null) {
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
@ -29,6 +31,13 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
recommendations = {};
recommendationAware = actualMinutes !== null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
}
if (Object.keys(recommendations).length) capacityAware = true;
openState = true;
return snapshot();
}
@ -62,6 +71,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
itemsById = new Map();
capacityMinutes = null;
estimates = {};
recommendations = {};
recommendationAware = false;
capacityAware = false;
}
@ -84,6 +95,15 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function applyRecommendation(id) {
const minutes = recommendations[id];
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
estimates[id] = minutes;
delete recommendations[id];
capacityAware = true;
return true;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
@ -111,6 +131,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
...(recommendationAware ? { recommendations:Object.fromEntries(
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
) } : {}),
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
@ -126,7 +149,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;

View File

@ -1,3 +1,18 @@
function todayRecapFeedbackRows(draft, describe = () => null) {
return (draft?.items || []).map(item => {
const work = describe(item.identity) || {};
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
return {
identity:item.identity,
label:String(work.title || item.identity).slice(0, 180),
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
estimate_minutes:estimate,
actual_minutes:item.actual_minutes,
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
};
});
}
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
@ -52,6 +67,19 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
restore();
return draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
};
const saveConfirmed = async includeActuals => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const actualMinutes = includeActuals ? Object.fromEntries(
payload.items.map(item => [item.identity, item.actual_minutes])
) : null;
const result = await save(payload);
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
};
return {
restore,
begin(entries, estimates = {}) {
@ -76,16 +104,8 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
return true;
},
snapshot,
async save() {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const result = await save(payload);
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return result;
},
save:() => saveConfirmed(false),
saveForReplan:() => saveConfirmed(true),
};
}
@ -99,18 +119,23 @@ async function saveTodayRecap(payload) {
return result;
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml }) {
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
const container = qs('#today-recap-items');
container.innerHTML = draft?.items.map(item =>
'<label class="today-recap-row"><span><strong>' + escapeHtml(item.identity) + '</strong><span class="small">' +
(item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
const rows = todayRecapFeedbackRows(draft, describeWork);
container.innerHTML = rows.map(item => {
const variance = item.variance_minutes === null ? 'No estimate comparison' :
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
return '<label class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
escapeHtml(item.identity) + '"> min</span></label>'
).join('') || '';
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span></label>';
}).join('') || '';
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
@ -153,9 +178,11 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
await recap.save();
const handoff = await recap.saveForReplan();
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(handoff.actual_minutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
@ -182,8 +209,9 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
else qs('#start-work-session').focus();
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml };
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
const recap = createTodayRecap({
save:saveTodayRecap, clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
@ -200,5 +228,6 @@ function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, close
if (typeof module !== 'undefined' && module.exports) {
createTodayRecap.createView = createTodayRecapView;
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
module.exports = createTodayRecap;
}

View File

@ -127,6 +127,43 @@ process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, sav
}]
def test_plan_today_applies_recap_actual_only_to_the_draft_after_explicit_action():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const saved = [];
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: plan => {{ saved.push(plan); return true; }},
}});
const first='issue:stackchain/dashboard:1:';
const second='issue:stackchain/dashboard:2:';
planner.open([item(1), item(2)], [item(1), item(2)], {{
capacity_minutes:120, estimates:{{[first]:30,[second]:45}}
}}, {{[first]:52,[second]:45}});
const before=planner.snapshot();
const ignored=planner.applyRecommendation(second);
const applied=planner.applyRecommendation(first);
const after=planner.snapshot();
const result=planner.commit();
process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}}));
"""
result = run_node(script)
assert result["before"]["estimates"] == {
"issue:stackchain/dashboard:1:": 30,
"issue:stackchain/dashboard:2:": 45,
}
assert result["before"]["recommendations"] == {"issue:stackchain/dashboard:1:": 52}
assert result["ignored"] is False
assert result["applied"] is True
assert result["after"]["estimates"]["issue:stackchain/dashboard:1:"] == 52
assert result["after"]["recommendations"] == {}
assert result["saved"][0]["estimates"] == {
"issue:stackchain/dashboard:1:": 52,
"issue:stackchain/dashboard:2:": 45,
}
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@ -247,6 +284,9 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert "formatPlanMinutes(state.planned_minutes)" in html
assert "planToday.setCapacity" in html
assert "planToday.setEstimate" in html
assert 'data-plan-recommendation="' in html
assert "planToday.applyRecommendation" in html
assert "Use " in html and " as new estimate" in html
assert "todaySync.enqueueConfiguration" in html
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
assert "resetPlanTodayConfirmation()" in html

View File

@ -119,6 +119,41 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
assert output["cleared"] == 1
def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const rows=createRecap.feedbackRows({{
items:[
{{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}},
{{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}},
]
}}, identity => identity.startsWith('issue:') ? {{
title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583'
}} : null);
process.stdout.write(JSON.stringify(rows));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == [
{
"identity": "issue:stackchain/dashboard:583:",
"label": "Turn recap feedback into a plan",
"context": "stackchain/dashboard#583",
"estimate_minutes": 30,
"actual_minutes": 52,
"variance_minutes": 22,
},
{
"identity": "pull:stackchain/api:9:",
"label": "pull:stackchain/api:9:",
"context": "Work details unavailable",
"estimate_minutes": None,
"actual_minutes": 12,
"variance_minutes": None,
},
]
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
@ -238,6 +273,39 @@ recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}
def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
let fail=true;
const recap=createRecap({{
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}),
clear:()=>{{}}, makeId:()=> 'replan-session'
}});
recap.begin([
{{identity:'issue:r:1:',elapsed_ms:52*60000}},
{{identity:'issue:r:2:',elapsed_ms:7*60000}},
], {{'issue:r:1:':30,'issue:r:2:':7}});
(async()=>{{
let failed;
try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }}
const retained=recap.snapshot();
fail=false;
const confirmed=await recap.saveForReplan();
process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["failed"] == "offline"
assert output["retained"]["session_id"] == "replan-session"
assert output["confirmed"] == {
"result": {"saved": True},
"actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7},
}
assert output["after"] is None
@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html
@ -259,3 +327,11 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert ".today-recap-header button { min-height:44px;" in css
assert ".today-recap-actions button { min-height:44px;" in css
assert "overflow-x:hidden" in css
assert "Save recap &amp; adjust plan" in html
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source
assert "adjustPlan(handoff.actual_minutes)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css