Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
This commit is contained in:
commit
528d721cd3
|
|
@ -134,6 +134,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||||
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
|
||||||
|
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
|
||||||
|
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
|
||||||
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
|
||||||
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
.plan-today-error { min-height:1.4em; color:#fca5a5; }
|
||||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||||
|
|
|
||||||
|
|
@ -1394,6 +1394,11 @@
|
||||||
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
|
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
|
||||||
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
|
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
|
||||||
qs('#plan-today-available').value = state.capacity_minutes || '';
|
qs('#plan-today-available').value = state.capacity_minutes || '';
|
||||||
|
const build = state.build;
|
||||||
|
qs('#plan-today-build-status').textContent = build ?
|
||||||
|
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
|
||||||
|
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
|
||||||
|
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
|
||||||
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
|
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
|
||||||
planTodayItemMarkup(planToday.item(id), true, index)
|
planTodayItemMarkup(planToday.item(id), true, index)
|
||||||
).join('') : '<p class="muted">No work selected yet.</p>';
|
).join('') : '<p class="muted">No work selected yet.</p>';
|
||||||
|
|
@ -1485,6 +1490,32 @@
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function buildTodayPlan() {
|
||||||
|
const button = qs('#build-today-plan');
|
||||||
|
const state = planToday.snapshot();
|
||||||
|
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
|
||||||
|
button.disabled = true;
|
||||||
|
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
|
||||||
|
const checks = await Promise.all(candidates.map(async item => {
|
||||||
|
const id = todayWork.identity(item);
|
||||||
|
if (item.kind !== 'issue') return [id, { status:'ready' }];
|
||||||
|
try {
|
||||||
|
const detail = await inspectTodayDependencies(item);
|
||||||
|
if (!detail.available) return [id, { status:'unverified' }];
|
||||||
|
if (detail.dependencies.length) return [id, {
|
||||||
|
status:'blocked', reason:detail.dependencies.length + ' open ' +
|
||||||
|
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
|
||||||
|
}];
|
||||||
|
return [id, { status:'ready' }];
|
||||||
|
} catch (_error) {
|
||||||
|
return [id, { status:'unverified' }];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
planToday.buildRecommendation(Object.fromEntries(checks));
|
||||||
|
button.disabled = false;
|
||||||
|
renderPlanToday();
|
||||||
|
}
|
||||||
|
|
||||||
function canPreviewPlanItem(item) {
|
function canPreviewPlanItem(item) {
|
||||||
if (!item || !offlineWorkMode) return Boolean(item);
|
if (!item || !offlineWorkMode) return Boolean(item);
|
||||||
const login = planningOwnerLogin || confirmedOwnerLogin ||
|
const login = planningOwnerLogin || confirmedOwnerLogin ||
|
||||||
|
|
@ -5357,6 +5388,7 @@
|
||||||
qs('#plan-today-error').textContent = '';
|
qs('#plan-today-error').textContent = '';
|
||||||
renderPlanToday();
|
renderPlanToday();
|
||||||
});
|
});
|
||||||
|
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
|
||||||
function commitPlanToday(start, button) {
|
function commitPlanToday(start, button) {
|
||||||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||||
if (result === 'saved') {
|
if (result === 'saved') {
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,8 @@
|
||||||
<label class="plan-today-available" for="plan-today-available">Available today
|
<label class="plan-today-available" for="plan-today-available">Available today
|
||||||
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
|
||||||
</label>
|
</label>
|
||||||
|
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
|
||||||
|
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
|
||||||
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
|
||||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||||
<section aria-labelledby="today-plan-heading">
|
<section aria-labelledby="today-plan-heading">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
let recommendations = {};
|
let recommendations = {};
|
||||||
let recommendationAware = false;
|
let recommendationAware = false;
|
||||||
let capacityAware = false;
|
let capacityAware = false;
|
||||||
|
let buildState = null;
|
||||||
|
|
||||||
function cleanItems(items) {
|
function cleanItems(items) {
|
||||||
const unique = new Map();
|
const unique = new Map();
|
||||||
|
|
@ -33,6 +34,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
}
|
}
|
||||||
recommendations = {};
|
recommendations = {};
|
||||||
recommendationAware = actualMinutes !== null;
|
recommendationAware = actualMinutes !== null;
|
||||||
|
buildState = null;
|
||||||
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
|
||||||
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
|
||||||
estimates[id] !== minutes) recommendations[id] = minutes;
|
estimates[id] !== minutes) recommendations[id] = minutes;
|
||||||
|
|
@ -74,6 +76,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
recommendations = {};
|
recommendations = {};
|
||||||
recommendationAware = false;
|
recommendationAware = false;
|
||||||
capacityAware = false;
|
capacityAware = false;
|
||||||
|
buildState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancel() {
|
function cancel() {
|
||||||
|
|
@ -104,6 +107,40 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecommendation(readiness = {}) {
|
||||||
|
if (!openState) return null;
|
||||||
|
const selected = [];
|
||||||
|
const reasons = {};
|
||||||
|
const skipped = [];
|
||||||
|
const needsEstimate = [];
|
||||||
|
let used = 0;
|
||||||
|
for (const [id, item] of itemsById) {
|
||||||
|
if (selected.length >= limit) break;
|
||||||
|
const check = readiness[id] || { status:'unverified' };
|
||||||
|
if (check.status !== 'ready') {
|
||||||
|
skipped.push({
|
||||||
|
id,
|
||||||
|
status:check.status === 'blocked' ? 'blocked' : 'unverified',
|
||||||
|
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const minutes = estimates[id];
|
||||||
|
if (!Number.isInteger(minutes)) {
|
||||||
|
needsEstimate.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
|
||||||
|
selected.push(id);
|
||||||
|
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
|
||||||
|
used += minutes;
|
||||||
|
}
|
||||||
|
draftIds = selected;
|
||||||
|
capacityAware = true;
|
||||||
|
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
|
||||||
|
return buildState;
|
||||||
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|
@ -138,6 +175,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
||||||
remaining_minutes: remainingMinutes,
|
remaining_minutes: remainingMinutes,
|
||||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||||
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
|
||||||
|
...(buildState ? { build:buildState } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +187,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, applyRecommendation, commit, snapshot, item, candidates };
|
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,49 @@ process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_today_builds_ranked_ready_capacity_fit_without_saving():
|
||||||
|
script = f"""
|
||||||
|
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||||
|
const item = (number, reason) => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, reason}});
|
||||||
|
const saved = [];
|
||||||
|
+const planner = createPlanToday({{
|
||||||
|
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||||
|
save: plan => saved.push(plan),
|
||||||
|
}});
|
||||||
|
const id = n => 'issue:stackchain/dashboard:' + n + ':';
|
||||||
|
planner.open([], [item(1, 'Due today'), item(2, 'Unread update'), item(3, 'Assigned to you'), item(4, 'Needs your review')], {{
|
||||||
|
capacity_minutes:90, estimates:{{[id(1)]:60,[id(2)]:45,[id(3)]:30,[id(4)]:20}}
|
||||||
|
}});
|
||||||
|
const build = planner.buildRecommendation({{
|
||||||
|
[id(1)]:{{status:'ready'}},
|
||||||
|
[id(2)]:{{status:'blocked', reason:'Waiting on API'}},
|
||||||
|
[id(3)]:{{status:'ready'}},
|
||||||
|
[id(4)]:{{status:'unverified'}},
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}}));
|
||||||
|
""".replace("+const", "const")
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["build"] == {
|
||||||
|
"selected": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"],
|
||||||
|
"reasons": {
|
||||||
|
"issue:stackchain/dashboard:1:": "Due today",
|
||||||
|
"issue:stackchain/dashboard:3:": "Assigned to you",
|
||||||
|
},
|
||||||
|
"skipped": [
|
||||||
|
{"id": "issue:stackchain/dashboard:2:", "status": "blocked", "reason": "Waiting on API"},
|
||||||
|
{"id": "issue:stackchain/dashboard:4:", "status": "unverified", "reason": "Could not verify readiness"},
|
||||||
|
],
|
||||||
|
"needs_estimate": [],
|
||||||
|
}
|
||||||
|
assert result["snapshot"]["ids"] == [
|
||||||
|
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"
|
||||||
|
]
|
||||||
|
assert result["snapshot"]["planned_minutes"] == 90
|
||||||
|
assert result["snapshot"]["remaining_minutes"] == 0
|
||||||
|
assert result["snapshot"]["build"] == result["build"]
|
||||||
|
assert result["saved"] == []
|
||||||
|
|
||||||
|
|
||||||
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))});
|
||||||
|
|
@ -278,6 +321,8 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||||
assert 'id="plan-today-sheet" role="dialog"' in html
|
assert 'id="plan-today-sheet" role="dialog"' in html
|
||||||
assert 'id="plan-today-capacity"' in html
|
assert 'id="plan-today-capacity"' in html
|
||||||
assert 'id="plan-today-available"' in html
|
assert 'id="plan-today-available"' in html
|
||||||
|
assert 'id="build-today-plan"' in html
|
||||||
|
assert 'id="plan-today-build-status" role="status" aria-live="polite"' in html
|
||||||
assert 'inputmode="numeric" min="15" max="1440"' in html
|
assert 'inputmode="numeric" min="15" max="1440"' in html
|
||||||
assert 'data-plan-estimate="' in html
|
assert 'data-plan-estimate="' in html
|
||||||
assert 'aria-label="Estimate for ' in html
|
assert 'aria-label="Estimate for ' in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user