Merge pull request 'Turn Today recap feedback into an adjusted plan' (#584) from timmy/583-recap-replan into main
This commit is contained in:
commit
c5ddb1275b
|
|
@ -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
|
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
|
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
||||||
as unknown rather than unblocked. Starting a Today work session also
|
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
|
After a reload or installed-app
|
||||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
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
|
**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, .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 strong { overflow-wrap:anywhere; }
|
||||||
.today-recap-row input { width:6rem; min-height:44px; }
|
.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-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 { 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%; }
|
.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 { 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-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; }
|
.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(
|
const todayRecapView = setupTodayRecap(
|
||||||
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
|
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) {
|
function updateDetailDeferLabels(active) {
|
||||||
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {
|
||||||
|
|
@ -1280,13 +1282,16 @@
|
||||||
const title = escapeHtml(item.title || 'Untitled work');
|
const title = escapeHtml(item.title || 'Untitled work');
|
||||||
const state = planToday.snapshot();
|
const state = planToday.snapshot();
|
||||||
const estimate = state.estimates?.[id] || '';
|
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 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 ?
|
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>' :
|
'<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' ?
|
(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 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>');
|
'<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() {
|
function resetPlanTodayConfirmation() {
|
||||||
|
|
@ -1343,6 +1348,11 @@
|
||||||
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
|
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
|
||||||
renderPlanToday();
|
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) {
|
function closePlanToday(navigate = true) {
|
||||||
|
|
@ -1618,17 +1628,21 @@
|
||||||
taskOverlayHistory.close();
|
taskOverlayHistory.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
function openPlanToday(trigger, navigate = true) {
|
let pendingPlanActualMinutes = null;
|
||||||
|
function openPlanToday(trigger, navigate = true, actualMinutes = null) {
|
||||||
if (!planningOwnerLogin) {
|
if (!planningOwnerLogin) {
|
||||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger) planTodayTrigger = trigger;
|
if (trigger) planTodayTrigger = trigger;
|
||||||
|
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('plan-today');
|
taskOverlayHistory.open('plan-today');
|
||||||
return;
|
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-error').textContent = '';
|
||||||
qs('#plan-today-sheet').hidden = false;
|
qs('#plan-today-sheet').hidden = false;
|
||||||
document.body.classList.add('task-overlay-open');
|
document.body.classList.add('task-overlay-open');
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@
|
||||||
<div id="today-recap-history" class="today-recap-history"></div>
|
<div id="today-recap-history" class="today-recap-history"></div>
|
||||||
</section>
|
</section>
|
||||||
<div class="today-recap-actions">
|
<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>
|
<button id="discard-today-recap" type="button">Keep timer & close</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
let itemsById = new Map();
|
let itemsById = new Map();
|
||||||
let capacityMinutes = null;
|
let capacityMinutes = null;
|
||||||
let estimates = {};
|
let estimates = {};
|
||||||
|
let recommendations = {};
|
||||||
|
let recommendationAware = false;
|
||||||
let capacityAware = false;
|
let capacityAware = false;
|
||||||
|
|
||||||
function cleanItems(items) {
|
function cleanItems(items) {
|
||||||
|
|
@ -15,7 +17,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(selectedItems, candidates, planning = null) {
|
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
|
||||||
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
|
||||||
draftIds = [];
|
draftIds = [];
|
||||||
for (const item of selectedItems || []) {
|
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 || {})) {
|
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
|
||||||
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
|
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;
|
openState = true;
|
||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +71,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
itemsById = new Map();
|
itemsById = new Map();
|
||||||
capacityMinutes = null;
|
capacityMinutes = null;
|
||||||
estimates = {};
|
estimates = {};
|
||||||
|
recommendations = {};
|
||||||
|
recommendationAware = false;
|
||||||
capacityAware = false;
|
capacityAware = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +95,15 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
return true;
|
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 } = {}) {
|
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
|
||||||
if (!openState) return 'closed';
|
if (!openState) return 'closed';
|
||||||
const ids = [...draftIds];
|
const ids = [...draftIds];
|
||||||
|
|
@ -111,6 +131,9 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
...basic,
|
...basic,
|
||||||
capacity_minutes: capacityMinutes,
|
capacity_minutes: capacityMinutes,
|
||||||
estimates: selectedEstimates,
|
estimates: selectedEstimates,
|
||||||
|
...(recommendationAware ? { recommendations:Object.fromEntries(
|
||||||
|
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
|
||||||
|
) } : {}),
|
||||||
planned_minutes: plannedMinutes,
|
planned_minutes: plannedMinutes,
|
||||||
remaining_minutes: remainingMinutes,
|
remaining_minutes: remainingMinutes,
|
||||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
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 [...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;
|
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 = () => '' }) {
|
function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
|
||||||
const totals = items => {
|
const totals = items => {
|
||||||
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
|
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();
|
restore();
|
||||||
return draft ? { ...draft, items:draft.items.map(item => ({...item})) } : null;
|
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 {
|
return {
|
||||||
restore,
|
restore,
|
||||||
begin(entries, estimates = {}) {
|
begin(entries, estimates = {}) {
|
||||||
|
|
@ -76,16 +104,8 @@ function createTodayRecap({ save, clear, makeId = () => crypto.randomUUID(), sto
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
snapshot,
|
snapshot,
|
||||||
async save() {
|
save:() => saveConfirmed(false),
|
||||||
restore();
|
saveForReplan:() => saveConfirmed(true),
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,18 +119,23 @@ async function saveTodayRecap(payload) {
|
||||||
return result;
|
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 minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
|
||||||
const render = () => {
|
const render = () => {
|
||||||
const draft = recap.snapshot();
|
const draft = recap.snapshot();
|
||||||
const container = qs('#today-recap-items');
|
const container = qs('#today-recap-items');
|
||||||
container.innerHTML = draft?.items.map(item =>
|
const rows = todayRecapFeedbackRows(draft, describeWork);
|
||||||
'<label class="today-recap-row"><span><strong>' + escapeHtml(item.identity) + '</strong><span class="small">' +
|
container.innerHTML = rows.map(item => {
|
||||||
(item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
|
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="' +
|
'</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 ' +
|
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
|
||||||
escapeHtml(item.identity) + '"> min</span></label>'
|
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span></label>';
|
||||||
).join('') || '';
|
}).join('') || '';
|
||||||
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
|
||||||
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
|
||||||
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
|
||||||
|
|
@ -153,9 +178,11 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
qs('#today-recap-status').textContent = 'Saving recap…';
|
qs('#today-recap-status').textContent = 'Saving recap…';
|
||||||
try {
|
try {
|
||||||
await recap.save();
|
const handoff = await recap.saveForReplan();
|
||||||
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
qs('#today-recap-status').textContent = 'Recap saved to your account.';
|
||||||
await loadHistory(); render(); button.hidden = true;
|
await loadHistory(); render(); button.hidden = true;
|
||||||
|
close();
|
||||||
|
adjustPlan(handoff.actual_minutes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
|
||||||
} finally { button.disabled = false; }
|
} finally { button.disabled = false; }
|
||||||
|
|
@ -182,8 +209,9 @@ function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
|
||||||
else qs('#start-work-session').focus();
|
else qs('#start-work-session').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin) {
|
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
|
||||||
const options = { timer, timerView, todayWork, api, qs, escapeHtml };
|
describeWork, adjustPlan) {
|
||||||
|
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
|
||||||
const recap = createTodayRecap({
|
const recap = createTodayRecap({
|
||||||
save:saveTodayRecap, clear:() => timer.clearRecap(), storage:localStorage, getLogin,
|
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) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
createTodayRecap.createView = createTodayRecapView;
|
createTodayRecap.createView = createTodayRecapView;
|
||||||
|
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
|
||||||
module.exports = createTodayRecap;
|
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():
|
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
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 "formatPlanMinutes(state.planned_minutes)" in html
|
||||||
assert "planToday.setCapacity" in html
|
assert "planToday.setCapacity" in html
|
||||||
assert "planToday.setEstimate" 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 "todaySync.enqueueConfiguration" in html
|
||||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
||||||
assert "resetPlanTodayConfirmation()" in html
|
assert "resetPlanTodayConfirmation()" in html
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,41 @@ recap.save().then(saved=>process.stdout.write(JSON.stringify({{
|
||||||
assert output["cleared"] == 1
|
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():
|
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createRecap = require({json.dumps(str(TODAY_RECAP))});
|
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}
|
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
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_renders_mobile_today_recap_flow():
|
async def test_dashboard_renders_mobile_today_recap_flow():
|
||||||
html = main.FRONTEND_BUILD.dashboard_html
|
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-header button { min-height:44px;" in css
|
||||||
assert ".today-recap-actions button { min-height:44px;" in css
|
assert ".today-recap-actions button { min-height:44px;" in css
|
||||||
assert "overflow-x:hidden" 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