Compare commits

...
Author SHA1 Message Date
timmy a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:55:52 +00:00
timmy 528d721cd3 Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 08:38:49 +00:00
timmy a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:28:03 +00:00
timmy 28a2c9faf1 Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 07:57:53 +00:00
timmy b8befc2041 fix: make Draft admission retry-safe (Closes #631)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:54:12 +00:00
timmy d23c0c894b Merge pull request 'File mobile Drafts in one sequential session' (#630) from timmy/629-sequential-draft-filing into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 07:20:10 +00:00
18 changed files with 287 additions and 24 deletions

No files matched your search

+9
View File
@@ -134,6 +134,15 @@ 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-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
.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; }
+57
View File
@@ -1394,6 +1394,24 @@
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' : '')) : '';
const estimateSection = qs('#plan-today-estimates');
estimateSection.hidden = !build?.needs_estimate.length;
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
const item = planToday.item(id);
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
return '<div class="plan-today-estimate-row"><div><span class="small">' + key + '</span><strong class="my-work-card-title">' +
escapeHtml(item?.title || 'Untitled work') + '</strong></div><label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-plan-missing-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item?.title || key) + ' in minutes" /> min</label></div>';
}).join('');
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
'<div class="small plan-today-skipped-item"><strong>' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
'</strong> · ' + escapeHtml(entry.reason) + '</div>'
).join('');
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>';
@@ -1429,6 +1447,17 @@
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
const id = input.dataset.planMissingEstimate;
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
input.focus();
return;
}
qs('#plan-today-error').textContent = '';
renderPlanToday();
document.querySelector('[data-plan-missing-estimate]')?.focus();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
@@ -1485,6 +1514,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 ||
@@ -4220,6 +4275,7 @@
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
@@ -5356,6 +5412,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') {
+8
View File
@@ -254,6 +254,14 @@
<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>
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
<div id="plan-today-estimate-list"></div>
</section>
<div class="plan-today-skipped" id="plan-today-skipped"></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">
+15
View File
@@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
@@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
function enqueue(draft, mirror = true) {
const items = read();
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) return { ...existing };
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
@@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
async function enqueueDurably(draft) {
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
}
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item: { ...existing }, background: true, durability: 'background', reused: true };
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
+48 -4
View File
@@ -7,6 +7,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -33,6 +35,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = 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 +78,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -88,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
@@ -104,6 +112,41 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
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 +181,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 +193,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;
+1 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v93';
const CACHE = 'stackchain-dashboard-shell-v94';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v94" in worker
+1 -1
View File
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v93';",
"const CACHE = 'stackchain-dashboard-shell-v94';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+52
View File
@@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence),
}});
const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}}));
"""
output = run_node(script)
assert output["sequence"] == 1
assert len(output["items"]) == 1
assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1"
assert output["items"][0]["operationId"] == "draft-op-1"
assert output["items"][0]["sourceCaptureId"] == "capture-7"
def test_durable_source_draft_retry_reconciles_without_allocating_another_operation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0; const reconciliations = []; let syncRequests = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence),
backgroundSync:{{
reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)),
requestSync:async()=>{{ syncRequests += 1; }},
}},
}});
(async()=>{{
const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}};
const first=await outbox.enqueueDurably(draft);
const retried=await outbox.enqueueDurably(draft);
process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert "reused" not in output["first"]
assert output["retried"]["reused"] is True
assert output["reconciliations"] == [["durable-1"], ["durable-1"]]
assert output["syncRequests"] == 2
assert len(output["items"]) == 1
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v94" in worker
+1 -1
View File
@@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v94" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v94" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+78 -1
View File
@@ -164,6 +164,77 @@ 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_estimates_ready_omission_and_refits_without_another_readiness_check():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
const id = n => 'issue:stackchain/dashboard:' + n + ':';
planner.open([], [item(1), item(2)], {{capacity_minutes:60, estimates:{{[id(1)]:30}}}});
planner.buildRecommendation({{[id(1)]:{{status:'ready'}}, [id(2)]:{{status:'ready'}}}});
const before = planner.snapshot();
const estimated = planner.setEstimate(id(2), 30);
const after = planner.snapshot();
process.stdout.write(JSON.stringify({{before, estimated, after}}));
"""
result = run_node(script)
assert result["before"]["ids"] == ["issue:stackchain/dashboard:1:"]
assert result["before"]["build"]["needs_estimate"] == ["issue:stackchain/dashboard:2:"]
assert result["estimated"] is True
assert result["after"]["ids"] == [
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"
]
assert result["after"]["estimates"]["issue:stackchain/dashboard:2:"] == 30
assert result["after"]["remaining_minutes"] == 0
assert result["after"]["build"]["needs_estimate"] == []
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 +349,12 @@ 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 'id="plan-today-estimates"' in html
assert 'Estimate to finish your plan' in html
assert 'data-plan-missing-estimate="' in html
assert '.plan-today-estimates input { min-height:44px;' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
@@ -333,6 +410,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+10 -10
View File
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/queue-today.js'" in source
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v94';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v94" in source
assert "BASE + 'static/today-sync.js'" in source
+1
View File
@@ -363,6 +363,7 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "createUnfiledAttachmentStore()" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html