Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08a51af02 | ||
|
|
528d721cd3 | ||
|
|
a91788276c | ||
|
|
28a2c9faf1 | ||
|
|
b8befc2041 | ||
|
|
d23c0c894b | ||
|
|
7948d2e353 | ||
|
|
1079ec6561 | ||
|
|
8d142c808b | ||
|
|
776ae70019 |
No files matched your search
+23
-1
@@ -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; }
|
||||
@@ -587,7 +600,16 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
+94
-28
@@ -55,11 +55,11 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
function openMobileAttention() {
|
||||
if (workSession.checkpointed()) timer.beginAttention();
|
||||
function openMobileQueue(name) {
|
||||
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
||||
renderAttentionInterruption();
|
||||
timerView.render();
|
||||
qs('[data-work-filter="attention"]').click();
|
||||
qs('[data-work-filter="' + name + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
@@ -79,15 +79,23 @@
|
||||
sessionHud: qs('[data-mobile-today-hud]'),
|
||||
buttons: mobileTaskButtons,
|
||||
workLabel: qs('#mobile-work-label'),
|
||||
attentionBadge: qs('#mobile-attention-count'),
|
||||
queueSheet: qs('#mobile-queue-sheet'),
|
||||
queueClose: qs('#close-mobile-queues'),
|
||||
queueRows: Object.fromEntries(
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||||
),
|
||||
queueCounts: Object.fromEntries(
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
|
||||
),
|
||||
queueBadge: qs('#mobile-queue-count'),
|
||||
onSelectQueue: openMobileQueue,
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
attention: openMobileAttention,
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
drafts: () => qs('[data-work-filter="draft"]').click(),
|
||||
queues: () => {},
|
||||
},
|
||||
observe(callback, overlays) {
|
||||
const observer = new MutationObserver(callback);
|
||||
@@ -96,14 +104,6 @@
|
||||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
const sourceDraftCount = qs('[data-work-count="draft"]');
|
||||
const draftCount = qs('#mobile-draft-count');
|
||||
function syncMobileDraftCount() {
|
||||
draftCount.textContent = sourceDraftCount.textContent;
|
||||
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
|
||||
}
|
||||
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
|
||||
syncMobileDraftCount();
|
||||
let liveMode = true;
|
||||
let offlineWorkMode = false;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
@@ -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 ||
|
||||
@@ -1989,6 +2050,7 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -2117,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);
|
||||
@@ -4052,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.';
|
||||
@@ -4072,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 = '';
|
||||
@@ -4082,7 +4141,7 @@
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('drafts');
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||||
requestAnimationFrame(() => {
|
||||
@@ -4216,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;
|
||||
@@ -5347,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') {
|
||||
|
||||
@@ -22,7 +22,7 @@ function bindDraftCapacityDialog() {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('drafts');
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
@@ -52,7 +52,7 @@ function bindDraftCapacityDialog() {
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('drafts');
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
|
||||
@@ -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;
|
||||
+31
-2
@@ -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" />
|
||||
@@ -838,13 +854,25 @@
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
@@ -858,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>
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
@@ -28,14 +28,38 @@
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
if (options.queueSheet?.open) options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
else options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
@@ -70,5 +94,19 @@
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateWork};
|
||||
function updateQueues(counts) {
|
||||
const names = ['today', 'attention', 'later', 'draft'];
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = names.reduce((sum, name) => sum + normalized[name], 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
+48
-4
@@ -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,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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -274,6 +274,82 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const queues = new FakeElement();
|
||||
const sheet = new FakeElement(); sheet.open = false;
|
||||
sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
|
||||
onSelectQueue:name=>selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.later.click();
|
||||
const closedAfterSelect = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, selected,
|
||||
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
queueFocuses:queues.focuses,
|
||||
columnState:nav.attributes['data-attention'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"closedAfterSelect": True,
|
||||
"selected": ["later"],
|
||||
"badge": "10",
|
||||
"badgeLabel": "Queues, 10 items",
|
||||
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
|
||||
"queueFocuses": 2,
|
||||
"columnState": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-mobile-task="queues"' in html
|
||||
assert 'id="mobile-queue-sheet"' in html
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
@@ -301,27 +377,25 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
|
||||
for task in ("work", "attention", "find", "new", "search", "drafts"):
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "queues"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "attention: openMobileAttention" in html
|
||||
assert "qs('[data-work-filter=\"attention\"]').click()" in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "queues: () => {}" in html
|
||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
@@ -358,7 +432,7 @@ async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
||||
|
||||
assert 'class="attention-interruption" id="attention-interruption"' in html
|
||||
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
||||
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
|
||||
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
|
||||
assert 'timer.returnFromAttention()' in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "attentionInterruption.hidden = !pending" in html
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))});
|
||||
@@ -330,18 +353,19 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||
assert "unfiledCaptures.save(captureDraft)" in html
|
||||
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
|
||||
assert "mobileTaskDock.select('drafts')" in html
|
||||
assert "mobileTaskDock.select('queues')" in html
|
||||
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user