feat: require estimate before create and start (Closes #849)
CI / lint (pull_request) Successful in 1m48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-14 20:41:57 +00:00
1 parent 8edc83893f
commit 5c9420ed9c
10 files changed
+253 -14

No files matched your search

+50 -2
View File
@@ -5,7 +5,40 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return todayWork.read().length < todayWork.limit;
}
function complete(issue) {
function capacity(value) {
if (String(value ?? '').trim() === '') return { valid: false, reason: 'required' };
const minutes = Number(value);
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) {
return { valid: false, reason: 'range' };
}
const plan = todayWork.planning();
const capacityMinutes = plan.capacity_minutes;
if (!Number.isInteger(capacityMinutes)) {
return { valid: true, minutes, capacityMinutes: null, plannedMinutes: null,
remainingMinutes: null, projectedMinutes: null, fits: true };
}
const plannedMinutes = todayWork.read().reduce(
(total, identity) => total + (Number.isInteger(plan.estimates[identity]) ? plan.estimates[identity] : 0), 0
);
const remainingMinutes = capacityMinutes - plannedMinutes;
const projectedMinutes = remainingMinutes - minutes;
return { valid: true, minutes, capacityMinutes, plannedMinutes, remainingMinutes,
projectedMinutes, fits: projectedMinutes >= 0 };
}
function capacityMessage(result) {
if (!result.valid) return result.reason === 'required'
? 'Required only for Create & start.'
: 'Use a whole number from 5 to 1440 minutes.';
if (result.capacityMinutes === null) {
return result.minutes + ' min · Set a Today budget to see remaining time.';
}
return result.fits
? result.minutes + ' min · ' + result.projectedMinutes + ' min will remain in Today.'
: result.minutes + ' min · ' + Math.abs(result.projectedMinutes) + ' min over Today capacity.';
}
function complete(issue, estimateMinutes) {
const identity = todayWork.identity(issue);
if (!identity || completed.has(identity)) return 'exists';
const added = todayWork.add(issue);
@@ -17,10 +50,25 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
announce('Created, but Today could not be saved on this device.');
return 'unavailable';
}
let estimatedPlan = null;
if (Number.isInteger(estimateMinutes)) {
estimatedPlan = todayWork.planning();
estimatedPlan.estimates[identity] = estimateMinutes;
if (!todayWork.replacePlanning(estimatedPlan)) {
announce('Created and added to Today, but its estimate could not be saved on this device.');
return 'estimate-unavailable';
}
}
if (!todaySync.enqueue('add', identity)) {
announce('Created and saved to Today on this device, but account sync is unavailable.');
return 'sync-unavailable';
}
if (estimatedPlan) {
if (!todaySync.enqueueConfiguration(estimatedPlan.capacity_minutes, estimatedPlan.estimates)) {
announce('Created and estimated on this device, but Today plan sync is unavailable.');
return 'sync-unavailable';
}
}
completed.add(identity);
refresh();
todaySync.flush();
@@ -30,7 +78,7 @@ function createCreateAndStart({ todayWork, todaySync, refresh, warm, start, anno
return 'started';
}
return { available, complete };
return { available, capacity, capacityMessage, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCreateAndStart;
+3
View File
@@ -292,6 +292,9 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.create-issue-estimate { display:grid; gap:6px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#101d31; }
.create-issue-estimate > span:first-of-type { display:flex; align-items:center; gap:8px; }
.create-issue-estimate input { min-height:44px; width:min(9rem,60vw); }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
+28 -2
View File
@@ -1594,6 +1594,12 @@
},
announce: message => { qs('#my-work-action-status').textContent = message; },
});
function renderCreateStartCapacity() {
const result = createAndStart.capacity(qs('#create-issue-estimate').value);
qs('#create-issue-estimate-status').textContent = createAndStart.capacityMessage(result);
return result;
}
qs('#create-issue-estimate').addEventListener('input', renderCreateStartCapacity);
const queueToday = createQueueToday({
todayWork,
@@ -3988,7 +3994,7 @@
const created = lastMyWork.find(item => item.kind === 'issue' &&
item.repository === completion.issue?.repository && item.number === completion.issue?.number);
if (!created) return;
const outcome = createAndStart.complete(created);
const outcome = createAndStart.complete(created, completion.estimateMinutes);
if (outcome === 'started' || outcome === 'exists') {
issueOutbox.completeIntent(completion.id, activeFlushLogin);
startedCompletions.add(created.repository + '#' + created.number);
@@ -5271,6 +5277,8 @@
(event.submitter.id === 'create-follow-up-next' ? 'follow-up-and-next' : 'create-and-assign');
const intent = pendingIssueFilingIntent;
createAndStartRequested = intent === 'create-and-start';
const startCapacity = createAndStartRequested
? createAndStart.capacity(qs('#create-issue-estimate').value) : null;
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
@@ -5288,6 +5296,21 @@
qs('#create-and-start-issue').focus();
return;
}
if (createAndStartRequested && !startCapacity.valid) {
qs('#create-issue-status').textContent = startCapacity.reason === 'required'
? 'Add a work estimate before creating and starting.'
: 'Use a whole-number estimate from 5 to 1440 minutes.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
if (createAndStartRequested && !startCapacity.fits) {
qs('#create-issue-status').textContent = 'This would put Today ' +
Math.abs(startCapacity.projectedMinutes) + ' minutes over capacity. Adjust the estimate or Today plan.';
qs('#create-issue-estimate').focus();
renderCreateStartCapacity();
return;
}
qs('#create-issue-status').textContent = 'Preparing complete filing review…';
try {
const evidence = await createIssueAttachmentController.serialize();
@@ -5298,7 +5321,10 @@
milestoneTitle: captureDraft.milestoneId ? milestoneOption?.textContent?.trim() : '',
...(Array.isArray(evidence) ? {attachments:evidence} : {attachment:evidence}),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
...(createAndStartRequested ? {
completionIntent: 'create-and-start', estimateMinutes: startCapacity.minutes,
todayCapacity: startCapacity,
} : {}),
};
filingReview.open({draft: durableDraft, intent}, event.submitter || qs('#submit-new-issue'));
qs('#create-issue-status').textContent = 'Review the complete payload, then confirm filing.';
+4
View File
@@ -838,6 +838,10 @@
<div id="create-issue-duplicate-list" class="create-issue-duplicate-list"></div>
<button id="create-issue-anyway" type="button" hidden>Create anyway</button>
</section>
<label class="create-issue-estimate" for="create-issue-estimate">Work estimate for Today
<span><input id="create-issue-estimate" type="number" inputmode="numeric" min="5" max="1440" step="5" placeholder="45" aria-describedby="create-issue-estimate-status" /> min</span>
<span id="create-issue-estimate-status" class="small" aria-live="polite">Required only for Create &amp; start.</span>
</label>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
+9
View File
@@ -107,11 +107,20 @@
const owner = draft.assignee
? 'Owner: ' + (draft.assigneeName || draft.assignee) + ' (@' + draft.assignee + ')'
: 'Assigned to you';
const planning = [];
if (Number.isInteger(draft.estimateMinutes)) {
planning.push('Estimate: ' + draft.estimateMinutes + ' min');
const projected = draft.todayCapacity?.projectedMinutes;
if (Number.isInteger(projected)) planning.push(projected >= 0
? 'Today after start: ' + projected + ' min free'
: 'Today after start: ' + Math.abs(projected) + ' min over capacity');
}
options.metadata.textContent = [
labels.length ? 'Labels: ' + labels.join(', ') : 'No labels',
'Milestone: ' + milestone,
'Due: ' + dueDate,
owner,
...planning,
].join(' · ');
if (options.blockerList) {
options.blockerList.replaceChildren(...(draft.blockers || []).map(blocker => {
+24 -3
View File
@@ -91,7 +91,13 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start' && !assignee) item.completionIntent = 'create-and-start';
if (draft?.completionIntent === 'create-and-start' && !assignee) {
item.completionIntent = 'create-and-start';
const estimateMinutes = Number(draft?.estimateMinutes);
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
item.estimateMinutes = estimateMinutes;
}
}
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
const attachments = captureAttachments(draft?.attachments);
@@ -216,6 +222,10 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
|| JSON.stringify(item.labelIds || []) !== JSON.stringify(nextLabelIds)
|| item.assignee !== nextAssignee || item.assigneeName !== nextAssigneeName
|| item.milestoneId !== nextMilestoneId || item.dueDate !== nextDueDate
|| item.estimateMinutes !== (() => {
const value = Number(draft?.estimateMinutes);
return Number.isInteger(value) && value >= 5 && value <= 1440 ? value : undefined;
})()
|| attachmentChanged || JSON.stringify(item.blockers || null) !== JSON.stringify(nextBlockers || null);
updated = {
...item,
@@ -227,8 +237,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
operationId: changed ? String(operationId()).slice(0, 128) : item.operationId,
status: 'queued',
};
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) updated.completionIntent = 'create-and-start';
else delete updated.completionIntent;
if (draft?.completionIntent === 'create-and-start' && !nextAssignee) {
updated.completionIntent = 'create-and-start';
const estimateMinutes = Number(draft?.estimateMinutes);
if (Number.isInteger(estimateMinutes) && estimateMinutes >= 5 && estimateMinutes <= 1440) {
updated.estimateMinutes = estimateMinutes;
} else delete updated.estimateMinutes;
} else {
delete updated.completionIntent;
delete updated.estimateMinutes;
}
if (nextAssignee === undefined) {
delete updated.assignee;
delete updated.assigneeName;
@@ -460,6 +478,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
});
}
@@ -499,6 +518,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
intent: result.item.completionIntent,
ownerLogin: result.item.ownerLogin,
operationId: result.item.operationId,
estimateMinutes: result.item.estimateMinutes,
issue: result.issue,
}] : [],
remaining: read(), blocked: 0,
@@ -543,6 +563,7 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
intent: item.completionIntent,
ownerLogin: item.ownerLogin,
operationId: item.operationId,
estimateMinutes: item.estimateMinutes,
issue: item.deliveredIssue,
}));
}