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
timmy 8d142c808b feat: unify mobile work queues (Closes #627)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 06:27:00 +00:00
timmy 776ae70019 Merge pull request 'Preserve mobile Drafts when attachment cleanup fails' (#626) from timmy/625-draft-mutation-rollback into main
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:56:46 +00:00
timmy 285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:54:19 +00:00
timmy 89d153d6a5 Merge pull request 'Prevent silent mobile Draft loss at capacity' (#624) from timmy/623-draft-capacity-guard into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:38:48 +00:00
timmy fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:36:32 +00:00
timmy 14ba1bcf14 Merge pull request 'Complete the mobile Save to Drafts handoff' (#622) from timmy/621-mobile-draft-handoff into main
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:57:56 +00:00
timmy a68639668c feat: complete mobile Draft handoff (Closes #621)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:54:45 +00:00
timmy 1f0e9fb59d Merge pull request 'Make PWA updates resilient with progressive feature caching' (#620) from timmy/619-progressive-feature-caching into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:26:38 +00:00
timmy 065275b4ef feat: progressively cache optional PWA features (Closes #619)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:24:18 +00:00
timmy 90f1c48565 Merge pull request 'Purge capture-first screenshot Drafts at every private-data boundary' (#618) from timmy/617-purge-capture-drafts into main
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:02:55 +00:00
timmy ae07dd185c fix: purge capture Draft screenshots (Closes #617)
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 03:58:10 +00:00
rockachopa f43f776479 Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 03:35:30 +00:00
timmy 48e4344f28 feat: preserve capture-first screenshots in Drafts (Closes #615)
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:33:19 +00:00
rockachopa 269916486c Merge pull request 'Make mobile New a capture-first flow' (#614) from timmy/613-mobile-capture-first into main
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 02:56:56 +00:00
30 changed files with 1304 additions and 121 deletions

No files matched your search

+10 -7
View File
@@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
queued base64 screenshot payloads remain readable and are converted only at delivery time.
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
+15
View File
@@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
return transact;
}
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
return {
put: (id, value) => transact(records => records.put({id, ...value})),
get: id => transact(async records => {
const value = await records.get(id);
if (!value) return null;
const {id: _id, ...attachment} = value;
return attachment;
}),
delete: id => transact(records => records.delete(id)),
};
}
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
@@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
}
+33 -2
View File
@@ -10,6 +10,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-capacity-actions button { min-height:44px; width:100%; }
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
#sign-out-all { min-height:44px; }
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.active-devices-sheet[hidden] { display:none; }
@@ -126,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; }
@@ -189,7 +206,8 @@ textarea { resize: vertical; min-height: 120px; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
@@ -202,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; }
@@ -578,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; }
+122 -31
View File
@@ -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';
@@ -400,11 +400,20 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
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();
@@ -1385,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>';
@@ -1420,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();
@@ -1476,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 ||
@@ -1940,7 +2004,8 @@
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
@@ -1985,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 + '"]');
@@ -2074,7 +2140,8 @@
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
return '<article class="my-work-card draft-card">' +
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
@@ -2107,16 +2174,13 @@
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
refreshMyWorkView();
openCreateIssueSheet();
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);
@@ -2191,10 +2255,10 @@
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
@@ -4046,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.';
@@ -4057,24 +4122,39 @@
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-repository-search').focus();
});
qs('#save-unfiled-issue').addEventListener('click', () => {
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
};
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
unfiledCaptures.save(captureDraft);
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
} catch (error) {
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog();
qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
@@ -4195,11 +4275,21 @@
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;
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;
refreshMyWorkView();
@@ -5322,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') {
+65
View File
@@ -0,0 +1,65 @@
function showDraftCapacityDialog(captures) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
qs('#draft-capacity-status').textContent = '';
qs('#draft-capacity-sheet').hidden = false;
qs('#keep-editing-draft').focus();
return true;
}
function bindDraftCapacityDialog() {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
});
qs('#review-full-drafts').addEventListener('click', () => {
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
saveIssueCaptureDraft();
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
card?.scrollIntoView({block:'nearest'});
card?.focus({preventScroll:true});
});
});
qs('#replace-oldest-draft').addEventListener('click', async () => {
const oldest = unfiledCaptures.capacity().oldest;
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
return;
}
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
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.';
} catch (error) {
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
} finally { button.disabled = false; }
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
+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;
+58 -14
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">
@@ -536,18 +544,6 @@
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<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" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<div class="issue-attachment-controls">
@@ -561,6 +557,26 @@
</div>
<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" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
@@ -593,6 +609,20 @@
</section>
</div>
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
<section class="draft-capacity-panel">
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
<div class="draft-capacity-actions">
<button id="review-full-drafts" type="button">Review Drafts</button>
<button id="keep-editing-draft" type="button">Keep editing</button>
<button id="replace-oldest-draft" type="button">Replace oldest &amp; save</button>
</div>
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
</section>
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<div class="update-sheet-header">
@@ -824,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>
@@ -844,6 +886,8 @@
<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>
<script src="static/issue-outbox.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' };
+43 -3
View File
@@ -9,14 +9,16 @@
let wasHidden = false;
function select(name) {
if (!buttons[name]) return false;
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
return true;
}
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');
@@ -26,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();
}
@@ -68,5 +94,19 @@
else nav.removeAttribute('data-attention');
}
return {start, 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
View File
@@ -7,6 +7,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -33,6 +35,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
@@ -74,6 +78,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -88,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
@@ -104,6 +112,41 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
const needsEstimate = [];
let used = 0;
for (const [id, item] of itemsById) {
if (selected.length >= limit) break;
const check = readiness[id] || { status:'unverified' };
if (check.status !== 'ready') {
skipped.push({
id,
status:check.status === 'blocked' ? 'blocked' : 'unverified',
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
});
continue;
}
const minutes = estimates[id];
if (!Number.isInteger(minutes)) {
needsEstimate.push(id);
continue;
}
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
selected.push(id);
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
used += minutes;
}
draftIds = selected;
capacityAware = true;
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
return buildState;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
@@ -138,6 +181,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
@@ -149,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+1
View File
@@ -54,6 +54,7 @@
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
+34 -5
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,8 @@ 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',
BASE + 'static/authored-outbox.js',
@@ -73,6 +75,8 @@ const SHELL = [
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
];
async function fetchNavigation(request) {
const controller = new AbortController();
@@ -117,8 +121,21 @@ async function withSessionCsrf(work) {
}
}
async function deletePrivateDatabase(name) {
if (!indexedDB) return;
await new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function purgeRevokedSessionData() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -155,6 +172,7 @@ async function offlineLeaseState(cache) {
async function expiredOfflineResponse() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -248,10 +266,16 @@ self.addEventListener('install', event => {
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await self.clients.claim();
})());
});
self.addEventListener('sync', event => {
@@ -269,6 +293,7 @@ self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
@@ -418,5 +443,9 @@ self.addEventListener('fetch', event => {
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});
+1
View File
@@ -456,6 +456,7 @@
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
+97 -10
View File
@@ -1,5 +1,6 @@
function createUnfiledCaptures({
storage,
attachmentStore = null,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
function capacity() {
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
const attachment = note?.attachment;
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
try { write(items); }
catch (error) {
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
: null;
if (!stage && !removed?.hasAttachment) return writeItems();
return Promise.resolve(stage).then(async () => {
if (!removed?.hasAttachment) return writeItems();
const removedAttachment = await attachmentStore.get(removed.id);
try {
await attachmentStore.delete(removed.id);
} catch (error) {
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
try { return writeItems(); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
throw error;
}
});
}
function save(note) {
const prepared = prepare(note);
const existing = read();
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
return persist(prepared, existing);
}
function replaceOldest(note, expectedOldestId) {
const prepared = prepare(note);
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
const oldest = existing.at(-1);
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
throw new Error('Drafts changed. Review them before replacing anything.');
}
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
try { write(remaining); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
return true;
})
);
}
function resume(id, confirmedLogin) {
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
return {...draft, attachment};
});
}
return {list, save, discard, resume};
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+12 -2
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/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
),
}
CACHE_DECLARATION = re.compile(
@@ -114,11 +115,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
+ f" BASE + '{eager_feature.runtime_name}',\n",
)
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
+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
+10 -5
View File
@@ -348,7 +348,8 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -373,7 +374,8 @@ process.stdout.write(JSON.stringify(state));
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -503,7 +505,8 @@ process.stdout.write(JSON.stringify(state));
]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -525,7 +528,8 @@ process.stdout.write(JSON.stringify(state));
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -620,7 +624,8 @@ process.stdout.write(JSON.stringify(state));
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+13 -1
View File
@@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
shell_block, optional_block = first.service_worker_source.split(
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
for name, bundle in first.feature_bundles.items():
if name == "today-timer":
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
capture_source = changed_frontend / "create-issue-sheet.js"
@@ -175,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
+118 -10
View File
@@ -116,6 +116,40 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const calls = [];
const makeButton = () => ({{
attributes: {{}},
setAttribute(name, value) {{ this.attributes[name] = value; }},
removeAttribute(name) {{ delete this.attributes[name]; }},
}});
const buttons = {{work:makeButton(), drafts:makeButton()}};
const dock = createDock({{
nav:{{}}, buttons,
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
}});
const selected = dock.select('drafts');
process.stdout.write(JSON.stringify({{
selected, calls,
work:buttons.work.attributes['aria-current'] || null,
drafts:buttons.drafts.attributes['aria-current'] || null,
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"selected": True,
"calls": [],
"work": None,
"drafts": "page",
}
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -240,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()
@@ -267,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
@@ -324,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
+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
+1
View File
@@ -46,6 +46,7 @@ const clear = createPrivateDeviceDataPurger({{
assert state["databases"] == [
"stackchain-background-outbox-v1",
"stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
+72 -12
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -24,6 +24,10 @@ storedResponses.set(
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
add: async url => {{
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
state.individuallyAdded.push(url);
}},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
@@ -58,6 +62,12 @@ const context = {{
}},
}},
importScripts: () => {{}},
indexedDB: {{deleteDatabase:name=>{{
state.deletedDatabases.push(name);
const request={{}};
queueMicrotask(()=>request.onsuccess?.());
return request;
}}}},
caches: {{
open: async () => cache,
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
@@ -83,7 +93,7 @@ const context = {{
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
async function dispatch(name, request) {{
let pending;
let response;
@@ -135,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
@@ -144,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
@@ -159,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
@@ -167,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
@@ -183,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
@@ -244,6 +254,9 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-revoked"}
@@ -331,6 +344,9 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
)
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["replies"] == [{"ok": True}]
@@ -638,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
@@ -668,6 +684,8 @@ 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",
"/dashboard/static/authored-outbox.js",
@@ -734,6 +752,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
result = run_worker_scenario(
"""
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
context.self.__testOptionalFeatures.push(
'/dashboard/feature-issue-capture-test.js',
'/dashboard/feature-security-center-test.js',
'/dashboard/feature-pull-workflow-test.js',
);
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["individuallyAdded"] == [
"/dashboard/feature-issue-capture-test.js",
"/dashboard/feature-pull-workflow-test.js",
]
def test_warmed_optional_feature_is_served_from_cache_while_offline():
result = run_worker_scenario(
"""
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
state.cachedBody = 'cached feature';
state.failFetch = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'cors',
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached feature"
assert result["state"]["fetches"] == []
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
@@ -773,6 +830,9 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
+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
+307 -7
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,7 +16,29 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
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))});
const values = new Map();
@@ -32,25 +55,203 @@ const captures = createUnfiledCaptures({{
createId:()=>String(++sequence),
now:()=>1000 + sequence,
}});
for (let index = 1; index <= 22; index += 1) {{
for (let index = 1; index <= 20; index += 1) {{
captures.save({{title:'Note ' + index, body:'Context ' + index}});
}}
let fullError = '';
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
fullError = error.message;
}}
const beforeReplace = captures.list();
const oldest = captures.capacity().oldest;
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
const offline = captures.list();
const restored = createUnfiledCaptures({{
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
}}).list();
process.stdout.write(JSON.stringify({{offline, restored}}));
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
"""
output = run_node(script)
assert output["fullError"] == "Drafts full — nothing was deleted."
assert len(output["beforeReplace"]) == 20
assert output["beforeReplace"][-1]["title"] == "Note 1"
assert output["oldest"]["title"] == "Note 1"
assert len(output["offline"]) == 20
assert output["offline"][0]["title"] == "Note 22"
assert output["offline"][-1]["title"] == "Note 3"
assert output["offline"][0]["title"] == "Note 21"
assert output["offline"][-1]["title"] == "Note 2"
assert all(item["quarantined"] for item in output["offline"])
assert all(not item["quarantined"] for item in output["restored"])
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const deleted = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{ deleted.push(id); blobs.delete(id); }},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
let mismatch = '';
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
catch (error) {{ mismatch = error.message; }}
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
process.stdout.write(JSON.stringify({{
mismatch, titles:captures.list().map(item=>item.title), deleted,
blobs:[...blobs.keys()], third:third.title,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"mismatch": "Drafts changed. Review them before replacing anything.",
"titles": ["Third", "Second"],
"deleted": ["1"],
"blobs": ["2", "3"],
"third": "Third",
}
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{
if (id === '1') throw new Error('IndexedDB delete failed');
blobs.delete(id);
}},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
let error = '';
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume(original.id, 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Original"],
"blobs": ["1"],
"resumedTitle": "Original",
"resumedFilename": "original.png",
}
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Keep this"],
"resumedTitle": "Keep this",
"resumedFilename": "proof.png",
}
def test_discard_metadata_write_failure_restores_attachment_and_draft():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
let failWrites = false;
const storage = {{
getItem:k=>values.get(k)||null,
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
failWrites = true;
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
failWrites = false;
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "Storage write failed",
"titles": ["Keep this"],
"blobIds": ["capture-1"],
"resumedFilename": "proof.png",
}
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -65,6 +266,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
let mismatch = '';
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
const resumed = captures.resume(first.id, 'timmy');
captures.completeResume(first.id);
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
"""
output = run_node(script)
@@ -74,6 +276,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
const listed = captures.list();
const resumed = await captures.resume(saved.id, 'timmy');
const beforeComplete = captures.list().length;
await captures.completeResume(saved.id);
process.stdout.write(JSON.stringify({{
listed, beforeComplete, afterComplete:captures.list().length,
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
blobRemoved:!blobs.has(saved.id),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output["listed"][0]["hasAttachment"] is True
assert "attachment" not in output["listed"][0]
assert output["beforeComplete"] == 1
assert output["afterComplete"] == 0
assert output["blobRemoved"] is True
assert output["resumed"] == {
"title": "Broken mobile layout",
"body": "At 320px",
"filename": "phone.png",
"contentType": "image/png",
"size": 5,
}
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -104,13 +352,62 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "createUnfiledCaptures({" in html
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" 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 dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
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()
assert 'id="draft-capacity-sheet"' in html
assert 'Drafts full — nothing was deleted.' in html
assert 'id="review-full-drafts"' in html
assert 'id="replace-oldest-draft"' in html
assert 'id="keep-editing-draft"' in html
assert "showDraftCapacityDialog(unfiledCaptures)" in html
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
assert '.draft-capacity-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
html = await dashboard()
@@ -125,3 +422,6 @@ async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_f
assert "qs('#file-new-issue').addEventListener('click'" in html
assert '.create-issue-capture-actions button { min-height:44px;' in html
assert '.create-issue-capture-actions[hidden] { display:none;' in html
attachment = html.index('class="create-issue-attachment"')
filing = html.index('class="create-issue-filing"')
assert attachment < filing