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
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
timmy 1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 06:29:46 +00:00
20 changed files with 463 additions and 42 deletions

No files matched your search

+13
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; }
@@ -211,6 +220,10 @@ 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; }
.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; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
+78 -13
View File
@@ -407,7 +407,13 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
let resumedUnfiledCaptureId = '';
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@@ -1388,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>';
@@ -1423,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();
@@ -1479,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 ||
@@ -2118,12 +2179,8 @@
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
resumedUnfiledCaptureId = item.capture_id;
await openCreateIssueSheet();
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
dFS.start(item.capture_id);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
@@ -4053,6 +4110,7 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
@@ -4073,9 +4131,9 @@
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (resumedUnfiledCaptureId) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
@@ -4217,14 +4275,20 @@
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const queued = admission.item;
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
@@ -5348,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') {
+92
View File
@@ -0,0 +1,92 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
dependencies.setFilingMode(true);
session.render();
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
+17
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">
@@ -550,6 +558,14 @@
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
<strong id="draft-filing-progress"></strong>
<span class="small" id="draft-filing-remaining"></span>
<div class="draft-filing-session-actions">
<button id="previous-draft" type="button">Previous</button>
<button id="skip-draft" type="button">Skip</button>
</div>
</aside>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
@@ -870,6 +886,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/background-issue-sync.js"></script>
+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;
+2 -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;
@@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
+2 -1
View File
@@ -29,7 +29,8 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
),
}
CACHE_DECLARATION = re.compile(
+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
+11 -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
@@ -684,6 +684,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
+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
+47 -4
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
def run_node(script: str):
@@ -15,6 +16,28 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
script = f"""
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
const session = createDraftFilingSession({{list:()=>captures}});
const opened = session.start('middle');
const skipped = session.next();
captures = [{{id:'newest'}}];
const afterFiled = session.removeCurrentAndNext('oldest');
const previous = session.previous();
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
"""
output = run_node(script)
assert output == {
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
}
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -335,13 +358,14 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" 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 "issueCapture.saveDraft(resumed)" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
@@ -349,6 +373,25 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
assert '<script src="static/draft-filing-session.js"></script>' in html
assert 'id="draft-filing-session"' in html
assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
html = await dashboard()