Turn Today recap feedback into an adjusted plan #584
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 & adjust plan</button>
|
||||
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user