Build my Today from ranked ready work #634
|
|
@ -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 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-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-error { min-height:1.4em; color:#fca5a5; }
|
||||
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
|
||||
|
|
|
|||
|
|
@ -1394,6 +1394,11 @@
|
|||
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
|
||||
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
|
||||
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) =>
|
||||
planTodayItemMarkup(planToday.item(id), true, index)
|
||||
).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) {
|
||||
if (!item || !offlineWorkMode) return Boolean(item);
|
||||
const login = planningOwnerLogin || confirmedOwnerLogin ||
|
||||
|
|
@ -5357,6 +5388,7 @@
|
|||
qs('#plan-today-error').textContent = '';
|
||||
renderPlanToday();
|
||||
});
|
||||
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
|
||||
function commitPlanToday(start, button) {
|
||||
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
|
||||
if (result === 'saved') {
|
||||
|
|
|
|||
|
|
@ -254,6 +254,8 @@
|
|||
<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>
|
||||
</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>
|
||||
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
|
||||
<section aria-labelledby="today-plan-heading">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
|||
let recommendations = {};
|
||||
let recommendationAware = false;
|
||||
let capacityAware = false;
|
||||
let buildState = null;
|
||||
|
||||
function cleanItems(items) {
|
||||
const unique = new Map();
|
||||
|
|
@ -33,6 +34,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
|||
}
|
||||
recommendations = {};
|
||||
recommendationAware = actualMinutes !== null;
|
||||
buildState = 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;
|
||||
|
|
@ -74,6 +76,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
|||
recommendations = {};
|
||||
recommendationAware = false;
|
||||
capacityAware = false;
|
||||
buildState = null;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
|
|
@ -104,6 +107,40 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
|||
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 } = {}) {
|
||||
if (!openState) return 'closed';
|
||||
const ids = [...draftIds];
|
||||
|
|
@ -138,6 +175,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
|
|||
remaining_minutes: remainingMinutes,
|
||||
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
|
||||
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 { 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;
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
script = f"""
|
||||
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-capacity"' 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 'data-plan-estimate="' in html
|
||||
assert 'aria-label="Estimate for ' in html
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user