Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
995d8f8527 | ||
|
|
fabd6123f8 | ||
|
|
2553e7121b | ||
|
|
681e10c7db | ||
|
|
d96a77c5d5 | ||
|
|
97eb928187 | ||
|
|
80a4096118 |
No files matched your search
@@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) {
|
||||
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
|
||||
const storageKey = 'stackchain.issue-capture.v1';
|
||||
const sharedStorageKey = 'stackchain.issue-share.v1';
|
||||
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
|
||||
let pending = null;
|
||||
let duplicateRequest = 0;
|
||||
let repositorySearchRequest = 0;
|
||||
@@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function pendingFollowUp() {
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
|
||||
const title = String(parsed.title || '').trim().slice(0, 240);
|
||||
const body = String(parsed.body || '').trim().slice(0, 9500);
|
||||
return title || body ? {repository, title, body, labelIds: []} : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function acceptFollowUp() {
|
||||
const followUp = pendingFollowUp();
|
||||
if (!followUp) return loadDraft();
|
||||
const accepted = saveDraft(followUp);
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function discardFollowUp() {
|
||||
try { storage.removeItem(followUpStorageKey); }
|
||||
catch (_error) { /* The existing capture remains authoritative. */ }
|
||||
}
|
||||
|
||||
function stageFollowUp(value) {
|
||||
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
|
||||
if (!followUp.title && !followUp.body) return {status: 'empty'};
|
||||
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
|
||||
catch (_error) { /* The in-page flow can still continue. */ }
|
||||
const existing = loadDraft();
|
||||
if (existing.title || existing.body) return {status: 'conflict'};
|
||||
acceptFollowUp();
|
||||
return {status: 'ready'};
|
||||
}
|
||||
|
||||
function loadLabels(repository) {
|
||||
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
|
||||
const priorities = new Set(['p0', 'priority-high', 'critical']);
|
||||
@@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
searchRepositories, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -208,6 +208,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.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); }
|
||||
.queue-finder { display:grid; gap:6px; margin:10px 0; }
|
||||
.queue-finder > label { font-weight:700; }
|
||||
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
|
||||
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
|
||||
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
|
||||
.queue-finder button { padding-inline:14px; }
|
||||
#search-older-work { width:100%; }
|
||||
.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; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
@@ -234,8 +241,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.update-selector { min-height:44px; }
|
||||
.work-selector { min-height:44px; }
|
||||
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
@@ -567,6 +576,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
|
||||
+163
-14
@@ -117,6 +117,7 @@
|
||||
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
|
||||
let selectedWorkFilter = 'all';
|
||||
let selectedWorkMilestone = 'all';
|
||||
let queueFindQuery = '';
|
||||
let savedWorkFilter = null;
|
||||
let launchFilterResolved = false;
|
||||
try {
|
||||
@@ -142,6 +143,8 @@
|
||||
let reviewTrigger = null;
|
||||
let offlineReview = false;
|
||||
let selectedUpdate = null;
|
||||
let selectedUpdateDetail = null;
|
||||
let followUpSourceUpdate = null;
|
||||
let updateTrigger = null;
|
||||
let selectedIssue = null;
|
||||
let selectedIssueOffline = false;
|
||||
@@ -407,6 +410,7 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
let updateFollowUp = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
storage: localStorage,
|
||||
@@ -496,6 +500,7 @@
|
||||
}, () => {
|
||||
if (!issueCapture) {
|
||||
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
|
||||
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
|
||||
}
|
||||
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
|
||||
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
|
||||
@@ -702,6 +707,7 @@
|
||||
renderMyWork();
|
||||
},
|
||||
});
|
||||
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
|
||||
const notificationPager = createNotificationPager({
|
||||
load: fetchNotificationPage,
|
||||
onNotifications: items => {
|
||||
@@ -807,6 +813,7 @@
|
||||
updateReplyAttachmentController.clear();
|
||||
}
|
||||
selectedUpdate = item;
|
||||
selectedUpdateDetail = null;
|
||||
updateMentions.dismiss();
|
||||
qs('#update-sheet').classList.add('open');
|
||||
qs('#update-sheet-key').textContent = item.key || '';
|
||||
@@ -825,17 +832,20 @@
|
||||
qs('#acknowledge-update-next').hidden = true;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#update-ownership-start').hidden = true;
|
||||
qs('#create-update-follow-up').hidden = true;
|
||||
qs('#retry-update-load').hidden = true;
|
||||
setOfflineUpdateControls(false);
|
||||
qs('#keep-update-unread').focus();
|
||||
},
|
||||
onDetail: async detail => {
|
||||
selectedUpdateDetail = detail;
|
||||
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
|
||||
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
|
||||
qs('#update-subject-state').textContent = detail.state || '';
|
||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||||
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||||
if (offlineWorkMode) {
|
||||
setOfflineUpdateControls(true);
|
||||
} else {
|
||||
@@ -2114,7 +2124,8 @@
|
||||
|
||||
function renderDrafts() {
|
||||
const list = qs('#my-work-list');
|
||||
const deliveryCenter = draftInbox.partition(lastDrafts);
|
||||
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
|
||||
const deliveryCenter = draftInbox.partition(displayedDrafts);
|
||||
const renderDraftCard = item => {
|
||||
const index = lastDrafts.indexOf(item);
|
||||
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
|
||||
@@ -2185,6 +2196,7 @@
|
||||
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
|
||||
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
|
||||
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
|
||||
updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
|
||||
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
|
||||
@@ -2318,13 +2330,17 @@
|
||||
qs('#load-more-notifications').hidden = true;
|
||||
return;
|
||||
}
|
||||
const visible = selectedWorkFilter === 'today' ?
|
||||
const queueItems = selectedWorkFilter === 'today' ?
|
||||
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
const visible = findQueueItems(queueItems, queueFindQuery);
|
||||
updateQueueFinder(visible.length, queueItems.length, incomplete);
|
||||
const selection = notificationSelection.snapshot();
|
||||
const selectedIds = new Set(selection.ids);
|
||||
const workSelectionState = workSelection.snapshot();
|
||||
const selectedWorkIds = new Set(workSelectionState.ids);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
const routeItem = routedWorkItem(item);
|
||||
@@ -2340,10 +2356,13 @@
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||||
item.has_update && Number.isInteger(item.notification_id);
|
||||
const selector = selectable ?
|
||||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
|
||||
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
|
||||
(selectedIds.has(item.notification_id) ? ' selected' : '');
|
||||
const workSelectable = workSelectionState.active;
|
||||
const workId = workSelection.identity(item);
|
||||
const selector = workSelectable ?
|
||||
'<label class="work-selector"><input type="checkbox" data-select-work-id="' + escAttr(workId) + '" data-work-index="' + index + '" aria-label="Select work ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedWorkIds.has(workId) ? ' checked' : '') + '> Select</label>' : (selectable ?
|
||||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '');
|
||||
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
|
||||
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
@@ -2384,6 +2403,16 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-select-work-id]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
const item = lastMyWork[Number(input.dataset.workIndex)];
|
||||
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
|
||||
if (result === 'limit') {
|
||||
input.checked = false;
|
||||
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -2507,18 +2536,30 @@
|
||||
|
||||
const updateIds = notificationIds(visible);
|
||||
const selectionControls = qs('#update-selection-controls');
|
||||
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
||||
qs('#select-updates').hidden = selection.active;
|
||||
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
|
||||
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
|
||||
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
|
||||
qs('#cancel-work-selection').hidden = !workSelectionState.active;
|
||||
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
|
||||
qs('#cancel-update-selection').hidden = !selection.active;
|
||||
qs('#update-selection-status').textContent = selection.active ?
|
||||
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
|
||||
qs('#update-selection-status').textContent = workSelectionState.active ?
|
||||
workSelectionState.count + ' selected' : (selection.active ?
|
||||
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
|
||||
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||||
document.querySelectorAll('[data-defer-selected]').forEach(button => {
|
||||
button.hidden = workSelectionState.active;
|
||||
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
|
||||
});
|
||||
qs('#batch-add-today').hidden = !workSelectionState.active;
|
||||
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||||
document.querySelectorAll('[data-batch-defer]').forEach(button => {
|
||||
button.hidden = !workSelectionState.active;
|
||||
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
|
||||
});
|
||||
bulkButton.hidden = workSelectionState.active;
|
||||
bulkButton.textContent = bulkConfirmationPending ?
|
||||
'Confirm marking ' + selection.count + ' selected read' :
|
||||
'Mark ' + selection.count + ' selected read';
|
||||
@@ -3314,6 +3355,7 @@
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
@@ -3338,6 +3380,12 @@
|
||||
clearTimeout(duplicateCheckTimer);
|
||||
qs('#create-issue-duplicates').hidden = true;
|
||||
creatingIssue = false;
|
||||
if (followUpSourceUpdate) {
|
||||
const source = followUpSourceUpdate;
|
||||
followUpSourceUpdate = null;
|
||||
notificationReader.open(source.item, source.detail);
|
||||
return;
|
||||
}
|
||||
qs('#new-issue').focus();
|
||||
}
|
||||
|
||||
@@ -3582,6 +3630,7 @@
|
||||
updateReplyAttachmentController.clear();
|
||||
qs('#update-sheet').classList.remove('open');
|
||||
selectedUpdate = null;
|
||||
selectedUpdateDetail = null;
|
||||
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
|
||||
else qs('[data-work-filter="update"]')?.focus();
|
||||
}
|
||||
@@ -4137,6 +4186,23 @@
|
||||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#create-update-follow-up').addEventListener('click', async () => {
|
||||
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
|
||||
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
|
||||
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
|
||||
updateFollowUp.stageSource(source.item);
|
||||
followUpSourceUpdate = source;
|
||||
closeUpdateSheet(false, false);
|
||||
await openCreateIssueSheet(false);
|
||||
if (state.status === 'conflict') {
|
||||
qs('#shared-content-conflict').hidden = false;
|
||||
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
|
||||
qs('#resume-issue-draft').focus();
|
||||
} else {
|
||||
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
|
||||
}
|
||||
qs('#create-follow-up-next').hidden = false;
|
||||
});
|
||||
dFS.bind();
|
||||
qs('#file-new-issue').addEventListener('click', () => {
|
||||
if (!qs('#create-issue-title').value.trim()) {
|
||||
@@ -4183,7 +4249,8 @@
|
||||
});
|
||||
bindDraftCapacityDialog();
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
issueCapture.acceptSharedContent();
|
||||
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
|
||||
else issueCapture.acceptSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
sharedLaunchState = null;
|
||||
clearSharedLaunchUrl();
|
||||
@@ -4192,6 +4259,8 @@
|
||||
});
|
||||
qs('#resume-issue-draft').addEventListener('click', () => {
|
||||
issueCapture.discardSharedContent();
|
||||
issueCapture.discardFollowUp();
|
||||
updateFollowUp.discardSource();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
sharedLaunchState = null;
|
||||
clearSharedLaunchUrl();
|
||||
@@ -4275,6 +4344,7 @@
|
||||
qs('#create-issue-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
|
||||
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
|
||||
const captureDraft = currentIssueCaptureDraft();
|
||||
if (!captureDraft.repository || !captureDraft.title) {
|
||||
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
|
||||
@@ -4294,8 +4364,10 @@
|
||||
}
|
||||
const button = qs('#submit-new-issue');
|
||||
const startButton = qs('#create-and-start-issue');
|
||||
const followUpButton = qs('#create-follow-up-next');
|
||||
button.disabled = true;
|
||||
startButton.disabled = true;
|
||||
followUpButton.disabled = true;
|
||||
qs('#create-issue-status').textContent = createAndStartRequested ?
|
||||
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
|
||||
try {
|
||||
@@ -4305,8 +4377,13 @@
|
||||
...(rUC ? { sourceCaptureId: rUC } : {}),
|
||||
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
|
||||
};
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const admission = followUpNextRequested ? (await updateFollowUp.complete({
|
||||
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
issueOutbox.enqueueDurably(durableDraft),
|
||||
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
|
||||
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
|
||||
})).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)) {
|
||||
@@ -4326,6 +4403,8 @@
|
||||
return;
|
||||
}
|
||||
editingOutboxId = null;
|
||||
followUpSourceUpdate = null;
|
||||
if (!followUpNextRequested) updateFollowUp.discardSource();
|
||||
issueCapture.clearDraft();
|
||||
createIssueAttachmentController.clear();
|
||||
suppressCreateDraftOnHistoryClose = true;
|
||||
@@ -4340,6 +4419,7 @@
|
||||
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
button.disabled = false;
|
||||
startButton.disabled = !createAndStart.available();
|
||||
followUpButton.disabled = false;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
@@ -5509,6 +5589,44 @@
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#select-work').addEventListener('click', () => {
|
||||
if (notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
workSelection.start();
|
||||
document.querySelector('[data-select-work-id]')?.focus();
|
||||
});
|
||||
qs('#cancel-work-selection').addEventListener('click', () => {
|
||||
workSelection.cancel();
|
||||
qs('#select-work').focus();
|
||||
});
|
||||
qs('#batch-add-today').addEventListener('click', () => {
|
||||
const ids = new Set(workSelection.snapshot().ids);
|
||||
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||||
const result = todayWork.addMany(selectedItems);
|
||||
if (result.status === 'added') {
|
||||
result.ids.forEach(id => todaySync.enqueue('add', id));
|
||||
todaySync.flush();
|
||||
warmTodayOffline();
|
||||
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
|
||||
workSelection.cancel();
|
||||
} else {
|
||||
qs('#my-work-action-status').textContent = result.status === 'full' ?
|
||||
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
|
||||
(result.status === 'exists' ? 'Every selected item is already in Today.' :
|
||||
'Could not save Today on this device. Nothing was added; selection unchanged.');
|
||||
}
|
||||
refreshMyWorkView();
|
||||
});
|
||||
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
|
||||
const ids = new Set(workSelection.snapshot().ids);
|
||||
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
|
||||
const until = laterWork.presetUntil(button.dataset.batchDefer);
|
||||
const result = laterWork.deferMany(selectedItems, until);
|
||||
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||||
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
|
||||
'Could not save Later on this device. Selection unchanged.';
|
||||
if (result === 'deferred') workSelection.cancel();
|
||||
refreshMyWorkView();
|
||||
}));
|
||||
qs('#select-updates').addEventListener('click', () => {
|
||||
notificationSelection.start();
|
||||
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||||
@@ -5559,12 +5677,43 @@
|
||||
selectWorkQueue(button.dataset.workFilter);
|
||||
});
|
||||
});
|
||||
function updateQueueFinder(matches, loaded, incomplete) {
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
|
||||
qs('#queue-find-label').textContent = queueLabel;
|
||||
qs('#clear-queue-find').hidden = !queueFindQuery;
|
||||
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
|
||||
qs('#queue-find-status').textContent = queueFindQuery ?
|
||||
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
|
||||
(incomplete ? ' among loaded work.' : '.')) : '';
|
||||
}
|
||||
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
|
||||
qs('#queue-find-input').addEventListener('input', event => {
|
||||
queueFindQuery = event.target.value;
|
||||
renderMyWork();
|
||||
});
|
||||
qs('#clear-queue-find').addEventListener('click', () => {
|
||||
queueFindQuery = '';
|
||||
qs('#queue-find-input').value = '';
|
||||
renderMyWork();
|
||||
qs('#queue-find-input').focus();
|
||||
});
|
||||
qs('#search-older-work').addEventListener('click', () => {
|
||||
const notificationButton = qs('#load-more-notifications');
|
||||
const workButton = qs('#load-more-work');
|
||||
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
|
||||
if (!target || target.hidden || target.disabled) return;
|
||||
qs('#queue-find-status').textContent = 'Searching older ' +
|
||||
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
|
||||
target.click();
|
||||
});
|
||||
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
|
||||
const button = qs('[data-work-filter="' + filter + '"]');
|
||||
if (!button) return false;
|
||||
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
|
||||
selectedWorkFilter = filter;
|
||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
if (workSelection.snapshot().active) workSelection.cancel();
|
||||
savedWorkFilter = selectedWorkFilter;
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
|
||||
@@ -163,10 +163,21 @@
|
||||
</div>
|
||||
</section>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-work" type="button">Select work</button>
|
||||
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<form class="queue-finder" id="queue-finder" role="search">
|
||||
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
|
||||
<div class="queue-finder-row">
|
||||
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
|
||||
<button id="clear-queue-find" type="button" hidden>Clear</button>
|
||||
</div>
|
||||
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
|
||||
<button id="search-older-work" type="button" hidden>Search older work</button>
|
||||
</form>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
@@ -179,6 +190,9 @@
|
||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||
<div class="my-work-bulk-actions">
|
||||
<button id="batch-add-today" type="button" hidden>Add to Today</button>
|
||||
<button data-batch-defer="today" type="button" hidden>Later today</button>
|
||||
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
|
||||
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
|
||||
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
|
||||
<button id="bulk-mark-read" type="button"></button>
|
||||
@@ -604,6 +618,7 @@
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-follow-up-next" type="submit" hidden>Create follow-up & next</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
@@ -678,6 +693,7 @@
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||
@@ -911,6 +927,7 @@
|
||||
<script src="static/my-work.js"></script>
|
||||
<script src="static/notification-undo.js"></script>
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/work-selection.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/today-timer.js"></script>
|
||||
<script src="static/today-recap.js"></script>
|
||||
@@ -924,6 +941,7 @@
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/update-follow-up.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
<script src="static/later-and-start.js"></script>
|
||||
|
||||
+13
-1
@@ -404,7 +404,8 @@ function createNotificationReader({
|
||||
return true;
|
||||
},
|
||||
acceptReadAndNext(items, item = selected) {
|
||||
if (!item || selected !== item) return false;
|
||||
if (!item || (selected && selected !== item)) return false;
|
||||
selected = item;
|
||||
return advanceAfterRead(items, item);
|
||||
},
|
||||
async loadOlder() {
|
||||
@@ -813,6 +814,16 @@ function summarizeMyWork(items) {
|
||||
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
|
||||
}
|
||||
|
||||
function findQueueItems(items, query) {
|
||||
const needle = String(query || '').trim().toLowerCase();
|
||||
if (!needle) return (items || []).slice();
|
||||
return (items || []).filter(item => {
|
||||
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
|
||||
return [item?.repository, item?.key, number, item?.title]
|
||||
.some(value => String(value || '').toLowerCase().includes(needle));
|
||||
});
|
||||
}
|
||||
|
||||
function countMyWork(items) {
|
||||
return {
|
||||
all: items.length,
|
||||
@@ -836,6 +847,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
|
||||
buildMyWork.removeIssue = removeIssue;
|
||||
buildMyWork.summarizeMyWork = summarizeMyWork;
|
||||
buildMyWork.findQueueItems = findQueueItems;
|
||||
buildMyWork.countMyWork = countMyWork;
|
||||
buildMyWork.acknowledgeNotification = acknowledgeNotification;
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v96';
|
||||
const CACHE = 'stackchain-dashboard-shell-v97';
|
||||
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;
|
||||
@@ -34,6 +34,7 @@ const SHELL = [
|
||||
BASE + 'static/my-work.js',
|
||||
BASE + 'static/notification-undo.js',
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/work-selection.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/today-timer.js',
|
||||
BASE + 'static/today-recap.js',
|
||||
@@ -47,6 +48,7 @@ const SHELL = [
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/update-follow-up.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/later-and-start.js',
|
||||
|
||||
+16
-1
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
return write(ids) ? 'added' : 'unavailable';
|
||||
}
|
||||
|
||||
function addMany(items) {
|
||||
if (!storageKey()) return { status: 'unavailable', ids: [] };
|
||||
const current = read();
|
||||
const additions = [];
|
||||
for (const item of items || []) {
|
||||
const id = identity(item);
|
||||
if (!id) return { status: 'unavailable', ids: [] };
|
||||
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
|
||||
}
|
||||
if (!additions.length) return { status: 'exists', ids: [] };
|
||||
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
|
||||
return write(current.concat(additions)) ?
|
||||
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
|
||||
}
|
||||
|
||||
function replace(ids) {
|
||||
const unique = [];
|
||||
for (const id of ids || []) {
|
||||
@@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
};
|
||||
}
|
||||
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
|
||||
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||
@@ -0,0 +1,87 @@
|
||||
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
|
||||
const sourceKey = 'stackchain.update-follow-up-source.v1';
|
||||
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
|
||||
const safeRepository = value => {
|
||||
const candidate = String(value || '');
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
|
||||
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
|
||||
};
|
||||
const safeUrl = value => {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
|
||||
} catch (_error) { return ''; }
|
||||
};
|
||||
|
||||
function draft(detail = {}) {
|
||||
const source = safeUrl(detail.url || detail.latest_comment?.url);
|
||||
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
|
||||
const title = clean(detail.title, 228);
|
||||
const sections = [];
|
||||
if (source) sections.push('Source: ' + source);
|
||||
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
|
||||
return {
|
||||
repository: safeRepository(detail.repository),
|
||||
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
|
||||
body: sections.join('\n\n').slice(0, 9500),
|
||||
};
|
||||
}
|
||||
|
||||
function readSource() {
|
||||
const login = String(getLogin() || '').trim();
|
||||
if (!login) return null;
|
||||
try {
|
||||
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
|
||||
return record?.version === 1 && record.ownerLogin === login &&
|
||||
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
|
||||
} catch (_error) { return null; }
|
||||
}
|
||||
|
||||
function writeSource(record) {
|
||||
storage?.setItem(sourceKey, JSON.stringify(record));
|
||||
}
|
||||
|
||||
function stageSource(item = {}) {
|
||||
const ownerLogin = String(getLogin() || '').trim();
|
||||
const notificationId = Number(item.notification_id);
|
||||
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
|
||||
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
|
||||
const source = {
|
||||
notification_id: notificationId,
|
||||
repository: safeRepository(item.repository),
|
||||
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
|
||||
title: clean(item.title, 240), kind: 'update', has_update: true,
|
||||
};
|
||||
writeSource({ version:1, ownerLogin, source, admission:null });
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
function source() {
|
||||
const record = readSource();
|
||||
return record ? { ...record.source } : null;
|
||||
}
|
||||
|
||||
function discardSource() {
|
||||
if (!readSource()) return false;
|
||||
storage?.removeItem(sourceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function complete({ admit, queueRead, advance }) {
|
||||
const record = readSource();
|
||||
if (!record) throw new Error('Reopen the source update before continuing.');
|
||||
let admission = record.admission;
|
||||
if (!admission) {
|
||||
admission = await admit();
|
||||
writeSource({ ...record, admission });
|
||||
}
|
||||
await queueRead(record.source.notification_id);
|
||||
const advanced = await advance({ ...record.source });
|
||||
storage?.removeItem(sourceKey);
|
||||
return { admission, advanced:Boolean(advanced) };
|
||||
}
|
||||
|
||||
return { draft, stageSource, source, discardSource, complete };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
|
||||
@@ -0,0 +1,68 @@
|
||||
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
|
||||
function identity(item) {
|
||||
if (!item) return '';
|
||||
const kind = item.is_review ? 'review' : (item.kind || 'work');
|
||||
const number = Number.isInteger(item.number) ? item.number : '';
|
||||
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
|
||||
return [kind, item.repository || '', number, notification].join(':');
|
||||
}
|
||||
|
||||
function changed() {
|
||||
const state = snapshot();
|
||||
onChange(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function start() {
|
||||
active = true;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
return changed();
|
||||
}
|
||||
|
||||
function select(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) return 'selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(id);
|
||||
changed();
|
||||
return 'selected';
|
||||
}
|
||||
|
||||
function toggle(item) {
|
||||
if (!active) return 'inactive';
|
||||
const id = identity(item);
|
||||
if (!id) return 'invalid';
|
||||
if (selected.has(id)) {
|
||||
selected.delete(id);
|
||||
changed();
|
||||
return 'removed';
|
||||
}
|
||||
return select(item);
|
||||
}
|
||||
|
||||
function retain(items) {
|
||||
const retained = new Set((items || []).map(identity).filter(Boolean));
|
||||
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
|
||||
return changed();
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { active, count: selected.size, ids: Array.from(selected) };
|
||||
}
|
||||
|
||||
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
|
||||
@@ -22,7 +22,7 @@ COMMONJS_BROWSER_BRANCH = re.compile(
|
||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
||||
FEATURE_SOURCES = {
|
||||
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
|
||||
"issue-capture": ("static/create-issue-sheet.js",),
|
||||
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
@@ -30,7 +30,8 @@ FEATURE_SOURCES = {
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
@@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
|
||||
assert 'Sending <strong data-delivery-count="sending">' in html
|
||||
assert 'Needs attention <strong data-delivery-count="attention">' in html
|
||||
assert 'Authorize <strong data-delivery-count="authorization">' in html
|
||||
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
|
||||
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
|
||||
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
|
||||
assert "deliveryCenter.retryable.length" in html
|
||||
assert "item.status === 'sending' ? 'Sending'" in html
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v96';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v97';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||
assert ".markdown-content a { min-height:44px;" in css
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
@@ -45,7 +45,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-v96" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v96" in worker
|
||||
assert "stackchain-dashboard-shell-v97" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -25,6 +25,104 @@ PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
|
||||
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
|
||||
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
|
||||
|
||||
|
||||
def test_queue_finder_matches_repository_number_and_title_without_reordering():
|
||||
script = f"""
|
||||
const work = require({json.dumps(str(MY_WORK))});
|
||||
const items = [
|
||||
{{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
|
||||
{{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
|
||||
{{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
|
||||
];
|
||||
process.stdout.write(JSON.stringify({{
|
||||
repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
|
||||
number:work.findQueueItems(items, '#42').map(item => item.key),
|
||||
title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
|
||||
clear:work.findQueueItems(items, ' ').map(item => item.key),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"repo": ["stackchain/api#42"],
|
||||
"number": ["stackchain/api#42", "other/repo#42"],
|
||||
"title": ["stackchain/web#7"],
|
||||
"clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<form class="queue-finder" id="queue-finder" role="search">' in html
|
||||
assert 'id="queue-find-input"' in html
|
||||
assert 'aria-label="Find in active work queue"' in html
|
||||
assert 'id="clear-queue-find"' in html
|
||||
assert 'id="search-older-work"' in html
|
||||
assert 'id="queue-find-status" role="status" aria-live="polite"' in html
|
||||
assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
|
||||
mobile = html.index('@media (max-width: 600px)')
|
||||
assert '.queue-finder { position:sticky;' in html[mobile:]
|
||||
assert "findQueueItems(queueItems, queueFindQuery)" in html
|
||||
|
||||
|
||||
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
function storage(fail = false) {{
|
||||
const values = new Map();
|
||||
return {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
}}
|
||||
const first = {{kind:'issue',repository:'o/r',number:1}};
|
||||
const second = {{kind:'pull',repository:'o/r',number:2}};
|
||||
const third = {{kind:'issue',repository:'o/r',number:3}};
|
||||
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
|
||||
const admitted = today.addMany([first, first, second]);
|
||||
const existing = today.addMany([second, third]);
|
||||
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
|
||||
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
|
||||
"full": {"status": "full", "ids": []},
|
||||
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
|
||||
"broken": {"status": "unavailable", "ids": []},
|
||||
"brokenIds": [],
|
||||
}
|
||||
|
||||
|
||||
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(WORK_SELECTION))});
|
||||
const selection = create({{limit:3}});
|
||||
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
|
||||
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
|
||||
selection.start(); selection.select(issue); selection.select(pull);
|
||||
const before = selection.snapshot();
|
||||
selection.retain([pull]);
|
||||
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
|
||||
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
|
||||
"issueId": "issue:o/r:1:",
|
||||
}
|
||||
|
||||
|
||||
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
|
||||
@@ -5649,6 +5747,25 @@ async def test_selected_updates_can_be_deferred_together_without_marking_them_re
|
||||
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
|
||||
html = await dashboard()
|
||||
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert '<script src="static/work-selection.js"></script>' in html
|
||||
assert 'id="select-work"' in html
|
||||
assert 'id="batch-add-today"' in html
|
||||
assert 'createWorkSelection({ limit: 50' in html
|
||||
assert 'todayWork.addMany(selectedItems)' in html
|
||||
assert 'laterWork.deferMany(selectedItems, until)' in html
|
||||
assert 'data-select-work-id=' in html
|
||||
assert 'Select work ' in html
|
||||
assert "workSelectionState.count + ' selected'" in html
|
||||
assert '.work-selector { min-height:44px;' in html
|
||||
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||
assert "BASE + 'static/work-selection.js'" in service_worker
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
|
||||
html = await dashboard()
|
||||
|
||||
@@ -410,7 +410,7 @@ 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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" 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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" 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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" 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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" 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-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -654,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -697,6 +697,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/my-work.js",
|
||||
"/dashboard/static/notification-undo.js",
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/work-selection.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/today-timer.js",
|
||||
"/dashboard/static/today-recap.js",
|
||||
@@ -710,6 +711,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/update-follow-up.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/later-and-start.js",
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v96';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v97';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v96" in source
|
||||
assert "stackchain-dashboard-shell-v97" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js"
|
||||
CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_follow_up_derives_repository_editable_title_and_canonical_context():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const controller = createFollowUp();
|
||||
process.stdout.write(JSON.stringify(controller.draft({{
|
||||
repository: 'stackchain/stackchain-dashboard',
|
||||
title: 'Fix mobile queue',
|
||||
subject_type: 'Pull',
|
||||
url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42',
|
||||
latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }}
|
||||
}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"repository": "stackchain/stackchain-dashboard",
|
||||
"title": "Follow up: Fix mobile queue",
|
||||
"body": (
|
||||
"Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n"
|
||||
"Latest context:\n> Please preserve the unread state. Ship this on mobile."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const controller = createFollowUp();
|
||||
process.stdout.write(JSON.stringify(controller.draft({{
|
||||
repository: '../admin', title: '<script>alert(1)</script>',
|
||||
url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}}
|
||||
}})));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["repository"] == ""
|
||||
assert output["title"] == "Follow up: <script>alert(1)</script>"
|
||||
assert "javascript:" not in output["body"]
|
||||
assert len(output["body"]) <= 9500
|
||||
|
||||
|
||||
def test_follow_up_continuation_is_account_bound_and_survives_reload():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const first = createFollowUp({{storage, getLogin:()=>login}});
|
||||
const staged = first.stageSource({{
|
||||
notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665,
|
||||
title:'Unread follow-up source', kind:'update', has_update:true
|
||||
}});
|
||||
const restored = createFollowUp({{storage, getLogin:()=>login}}).source();
|
||||
login = 'alexander';
|
||||
const isolated = createFollowUp({{storage, getLogin:()=>login}}).source();
|
||||
process.stdout.write(JSON.stringify({{staged, restored, isolated}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["staged"]["notification_id"] == 42
|
||||
assert output["restored"] == output["staged"]
|
||||
assert output["isolated"] is None
|
||||
|
||||
|
||||
def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const order = [];
|
||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
||||
(async () => {{
|
||||
const result = await controller.complete({{
|
||||
admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }},
|
||||
queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }},
|
||||
advance:async source=>{{ order.push('next:' + source.notification_id); return true; }},
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}}));
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output == {
|
||||
"order": ["issue", "read:42", "next:42"],
|
||||
"result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
def test_follow_up_completion_preserves_source_when_issue_admission_fails():
|
||||
script = f"""
|
||||
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const order = [];
|
||||
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
|
||||
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
|
||||
(async () => {{
|
||||
try {{
|
||||
await controller.complete({{
|
||||
admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }},
|
||||
queueRead:async()=>order.push('read'), advance:async()=>order.push('next'),
|
||||
}});
|
||||
}} catch (error) {{
|
||||
process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}}));
|
||||
}}
|
||||
}})();
|
||||
"""
|
||||
output = run_node(script)
|
||||
assert output["order"] == ["issue"]
|
||||
assert output["error"] == "disk full"
|
||||
assert output["pending"]["notification_id"] == 42
|
||||
|
||||
|
||||
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
|
||||
script = f"""
|
||||
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.has(key) ? values.get(key) : null,
|
||||
setItem:(key,value) => values.set(key,value), removeItem:key => values.delete(key)
|
||||
}};
|
||||
const capture = createCapture({{fetchJson:async()=>[], storage}});
|
||||
capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}});
|
||||
const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}});
|
||||
const before = capture.loadDraft();
|
||||
const accepted = capture.acceptFollowUp();
|
||||
process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"state": {"status": "conflict"},
|
||||
"before": {
|
||||
"repository": "o/existing",
|
||||
"title": "Existing draft",
|
||||
"body": "Keep me",
|
||||
"labelIds": [],
|
||||
},
|
||||
"accepted": {
|
||||
"repository": "o/new",
|
||||
"title": "Follow up",
|
||||
"body": "Source: https://example.test/1",
|
||||
"labelIds": [],
|
||||
},
|
||||
"pending": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'name="stackchain-feature-issue-capture"' in html
|
||||
assert 'id="create-update-follow-up"' in html
|
||||
assert '>Create follow-up</button>' in html
|
||||
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
|
||||
assert "qs('#create-update-follow-up').addEventListener('click'" in html
|
||||
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
|
||||
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_follow_up_capture_offers_durable_create_and_next_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-follow-up-next"' in html
|
||||
assert '>Create follow-up & next</button>' in html
|
||||
assert "updateFollowUp.stageSource(source.item)" in html
|
||||
assert "event.submitter?.id === 'create-follow-up-next'" in html
|
||||
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
|
||||
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
|
||||
assert ".create-issue-actions button { min-height:44px;" in html
|
||||
Reference in new issue
Block a user