Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 | ||
|
|
c31129f1cc | ||
|
|
4afe4dd3b9 | ||
|
|
985472eb06 | ||
|
|
8b36455c21 | ||
|
|
3ba1fe76f0 | ||
|
|
321474e8cf | ||
|
|
166d14dd35 | ||
|
|
7a62f7ead7 |
No files matched your search
@@ -0,0 +1,49 @@
|
||||
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
|
||||
let request = null;
|
||||
|
||||
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
|
||||
if (request) return request;
|
||||
if (!available()) {
|
||||
announce('Today is full—remove an item before assigning this issue.');
|
||||
return Promise.resolve('full');
|
||||
}
|
||||
request = Promise.resolve()
|
||||
.then(() => alreadyOwned ? item : claim(item))
|
||||
.then(confirmed => {
|
||||
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
|
||||
if (outcome === 'queued') {
|
||||
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
|
||||
'Assigned and queued in Today. Keep finding work when ready.');
|
||||
return outcome;
|
||||
}
|
||||
if (outcome === 'started') {
|
||||
announce(alreadyOwned ? 'Added to Today and ready to work.' :
|
||||
'Assigned, added to Today, and ready to work.');
|
||||
return outcome;
|
||||
}
|
||||
if (destination === 'queue') {
|
||||
if (outcome === 'sync-unavailable') {
|
||||
announce(alreadyOwned ?
|
||||
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
|
||||
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not be queued. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
|
||||
}
|
||||
} else {
|
||||
announce(alreadyOwned ?
|
||||
'Today could not start. The issue is open so you can recover.' :
|
||||
'Assigned to you, but Today could not start. The issue is open so you can recover.');
|
||||
}
|
||||
recover(confirmed);
|
||||
return 'recovery';
|
||||
})
|
||||
.finally(() => { request = null; });
|
||||
return request;
|
||||
}
|
||||
|
||||
return { run };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
|
||||
@@ -94,6 +94,10 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
|
||||
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
|
||||
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
|
||||
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
|
||||
.plan-preview-actions[hidden] { display:none; }
|
||||
.plan-preview-actions button { min-height:44px; }
|
||||
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||
.plan-today-actions button { min-height:44px; width:100%; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
@@ -259,6 +263,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.find-work-list { display:grid; gap:10px; }
|
||||
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||
.find-work-card button { width:100%; font-weight:700; }
|
||||
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
|
||||
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
|
||||
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
@@ -269,6 +275,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
|
||||
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
|
||||
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
|
||||
.markdown-content > :first-child { margin-top:0; }
|
||||
@@ -365,6 +372,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-panel { width:100%; border-left:0; padding:14px; }
|
||||
.plan-today-item { grid-template-columns:1fr; }
|
||||
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
|
||||
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
|
||||
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1 1 calc(50% - 8px); }
|
||||
|
||||
+238
-16
@@ -43,20 +43,30 @@
|
||||
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
|
||||
);
|
||||
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
|
||||
function openMobileWork() {
|
||||
function openMobileWorkFallback() {
|
||||
const counts = countMyWork(activeMyWork);
|
||||
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
|
||||
const filter = counts.attention ? 'attention' : 'all';
|
||||
qs('[data-work-filter="' + filter + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
getTodayCount: () => todayMyWork.length,
|
||||
continueToday: continueTodaySession,
|
||||
resumeToday: resumeTodaySession,
|
||||
startToday: startTodaySession,
|
||||
openFallback: openMobileWorkFallback,
|
||||
});
|
||||
const mobileTaskDock = createMobileTaskDock({
|
||||
nav: qs('#mobile-task-dock'),
|
||||
buttons: mobileTaskButtons,
|
||||
workLabel: qs('#mobile-work-label'),
|
||||
attentionBadge: qs('#mobile-attention-count'),
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: openMobileWork,
|
||||
work: () => mobileWorkEntry.open(),
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
@@ -678,6 +688,7 @@
|
||||
qs('#start-work-session').hidden = active;
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
@@ -709,6 +720,31 @@
|
||||
},
|
||||
});
|
||||
|
||||
function selectTodayWork() {
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
|
||||
function continueTodaySession() {
|
||||
selectTodayWork();
|
||||
if (!workSession.reopen() && !workSession.reconcile()) {
|
||||
qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
}
|
||||
|
||||
function resumeTodaySession() {
|
||||
selectTodayWork();
|
||||
if (!workSession.resume()) {
|
||||
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
|
||||
updateWorkSessionActions();
|
||||
}
|
||||
}
|
||||
|
||||
function startTodaySession() {
|
||||
selectTodayWork();
|
||||
workSession.start();
|
||||
}
|
||||
|
||||
const createAndStart = createCreateAndStart({
|
||||
todayWork,
|
||||
todaySync,
|
||||
@@ -721,6 +757,49 @@
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
|
||||
const queueToday = createQueueToday({
|
||||
todayWork,
|
||||
todaySync,
|
||||
refresh: refreshMyWorkView,
|
||||
warm: warmTodayOffline,
|
||||
});
|
||||
|
||||
function acceptClaimedIssue(confirmed) {
|
||||
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||||
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
|
||||
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
|
||||
));
|
||||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||
return lastMyWork.find(work =>
|
||||
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
|
||||
);
|
||||
}
|
||||
|
||||
const assignAndStart = createAssignAndStart({
|
||||
available: createAndStart.available,
|
||||
claim: item => findWorkController.claim(item),
|
||||
start: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(claimed);
|
||||
},
|
||||
queue: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
return queueToday(claimed);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
openRoutedWork(claimed, qs('#find-work'));
|
||||
},
|
||||
announce: message => {
|
||||
qs('#find-work-status').textContent = message;
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
});
|
||||
|
||||
let planTodayTrigger = null;
|
||||
function planTodayItemMarkup(item, selected, index = -1) {
|
||||
const id = todayWork.identity(item);
|
||||
@@ -728,7 +807,7 @@
|
||||
const title = escapeHtml(item.title || 'Untitled work');
|
||||
const controls = selected ?
|
||||
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
|
||||
'<button type="button" data-plan-add="' + escAttr(id) + '">Add</button>';
|
||||
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>';
|
||||
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
|
||||
}
|
||||
|
||||
@@ -747,6 +826,15 @@
|
||||
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
|
||||
renderPlanToday();
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-preview]').forEach(button => button.addEventListener('click', () => {
|
||||
const item = planToday.item(button.dataset.planPreview);
|
||||
if (!canPreviewPlanItem(item)) {
|
||||
qs('#plan-today-error').textContent = 'Preview unavailable offline. Reconnect or add the item without previewing it.';
|
||||
return;
|
||||
}
|
||||
qs('#plan-today-error').textContent = '';
|
||||
if (planTodayPreview.open(item, button)) taskOverlayHistory.open('plan-today-preview');
|
||||
}));
|
||||
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
|
||||
planToday.toggle(planToday.item(button.dataset.planRemove));
|
||||
qs('#plan-today-error').textContent = '';
|
||||
@@ -792,6 +880,58 @@
|
||||
},
|
||||
});
|
||||
|
||||
function canPreviewPlanItem(item) {
|
||||
if (!item || !offlineWorkMode) return Boolean(item);
|
||||
const login = planningOwnerLogin || confirmedOwnerLogin ||
|
||||
String(offlineWorkStore.load()?.user?.login || '').trim();
|
||||
return Boolean(offlineWorkStore.loadDetail(login, item));
|
||||
}
|
||||
|
||||
function openPlanPreviewDetail(item, trigger) {
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
qs('#plan-preview-actions').hidden = false;
|
||||
if (offlineWorkMode) {
|
||||
openRoutedWork(item, trigger);
|
||||
} else if (item.kind === 'update' && item.has_update) {
|
||||
updateTrigger = trigger;
|
||||
notificationReader.open(item, lastMyWork);
|
||||
} else if (item.is_review || item.kind === 'review') {
|
||||
reviewTrigger = trigger;
|
||||
openReviewSheet(item, trigger);
|
||||
} else if (item.kind === 'issue') {
|
||||
issueTrigger = trigger;
|
||||
openIssueSheet(item, trigger);
|
||||
} else if (item.kind === 'pull') {
|
||||
pullTrigger = trigger;
|
||||
openPullSheet(item, trigger);
|
||||
}
|
||||
}
|
||||
|
||||
const planTodayPreview = createPlanTodayPreview({
|
||||
planner: planToday,
|
||||
identity: item => todayWork.identity(item),
|
||||
getScroll: () => qs('.plan-today-panel').scrollTop,
|
||||
setScroll: value => requestAnimationFrame(() => { qs('.plan-today-panel').scrollTop = value; }),
|
||||
onOpen: openPlanPreviewDetail,
|
||||
onClose: (_item, trigger) => {
|
||||
closeOpenWorkSheets();
|
||||
qs('#plan-preview-actions').hidden = true;
|
||||
qs('#plan-today-sheet').hidden = false;
|
||||
renderPlanToday();
|
||||
requestAnimationFrame(() => trigger?.focus());
|
||||
},
|
||||
});
|
||||
let addPlanPreviewOnReturn = false;
|
||||
|
||||
qs('#back-to-plan').addEventListener('click', () => {
|
||||
addPlanPreviewOnReturn = false;
|
||||
taskOverlayHistory.close();
|
||||
});
|
||||
qs('#add-plan-preview').addEventListener('click', () => {
|
||||
addPlanPreviewOnReturn = true;
|
||||
taskOverlayHistory.close();
|
||||
});
|
||||
|
||||
function openPlanToday(trigger, navigate = true) {
|
||||
if (!planningOwnerLogin) {
|
||||
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||
@@ -1044,7 +1184,7 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileTaskDock.updateAttention(counts.attention);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
|
||||
' (' + (counts[selectedWorkFilter] || 0) + ')';
|
||||
@@ -1710,6 +1850,7 @@
|
||||
qs('#pull-ci-state').textContent = 'CI unknown';
|
||||
qs('#pull-merge-state').textContent = 'Review data not loaded';
|
||||
qs('#merge-pull').disabled = true;
|
||||
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
|
||||
qs('#retry-pull-load').hidden = true;
|
||||
qs('#open-pull-gitea').href = item.url || '#';
|
||||
qs('#close-pull-sheet').focus();
|
||||
@@ -1771,7 +1912,10 @@
|
||||
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
|
||||
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
|
||||
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
|
||||
detail + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
|
||||
detail + '<div class="find-work-claim-actions"><button type="button" data-claim-index="' + index +
|
||||
'">Assign</button><button type="button" data-claim-queue-index="' + index +
|
||||
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
|
||||
'">Start now</button></div></article>';
|
||||
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
|
||||
list.querySelectorAll('[data-preview-index]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
@@ -1791,12 +1935,7 @@
|
||||
button.disabled = true;
|
||||
try {
|
||||
const confirmed = await findWorkController.claim(item);
|
||||
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
|
||||
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
|
||||
lastMyWork = buildMyWork(lastContextSnapshot);
|
||||
const claimed = lastMyWork.find(work =>
|
||||
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
|
||||
);
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
|
||||
@@ -1808,6 +1947,41 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
|
||||
if (!item) return;
|
||||
const claimButtons = button.closest('.find-work-card')
|
||||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||||
claimButtons.forEach(action => { action.disabled = true; });
|
||||
try {
|
||||
const outcome = await assignAndStart.run(item, { destination:'queue' });
|
||||
if (outcome === 'queued') {
|
||||
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
|
||||
claimButtons.forEach(action => { action.disabled = false; });
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
|
||||
if (!item) return;
|
||||
const claimButtons = button.closest('.find-work-card')
|
||||
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
|
||||
claimButtons.forEach(action => { action.disabled = true; });
|
||||
try {
|
||||
await assignAndStart.run(item);
|
||||
} catch (error) {
|
||||
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
|
||||
claimButtons.forEach(action => { action.disabled = false; });
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openFindWorkSheet(navigate = true) {
|
||||
@@ -2501,6 +2675,7 @@
|
||||
const sheet = qs('#search-preview');
|
||||
const status = qs('#search-preview-status');
|
||||
const claimButton = qs('#claim-search-result');
|
||||
const startButton = qs('#start-search-result');
|
||||
if (state.status === 'closed') {
|
||||
sheet.classList.remove('open');
|
||||
return;
|
||||
@@ -2508,6 +2683,8 @@
|
||||
sheet.classList.add('open');
|
||||
claimButton.hidden = true;
|
||||
claimButton.disabled = false;
|
||||
startButton.hidden = true;
|
||||
startButton.disabled = false;
|
||||
if (state.status === 'loading') {
|
||||
searchPreviewDetail = null;
|
||||
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
|
||||
@@ -2537,6 +2714,10 @@
|
||||
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
|
||||
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
|
||||
claimButton.disabled = state.status === 'claiming';
|
||||
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
|
||||
(detail.claimable || detail.assigned_to_me));
|
||||
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
|
||||
startButton.disabled = state.status === 'claiming';
|
||||
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
|
||||
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
|
||||
(detail.claimable ? 'This issue is open and unassigned.' :
|
||||
@@ -2551,6 +2732,26 @@
|
||||
),
|
||||
onState: renderSearchPreview,
|
||||
});
|
||||
const searchAssignAndStart = createAssignAndStart({
|
||||
available: createAndStart.available,
|
||||
claim: detail => searchPreview.claim(detail),
|
||||
start: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
return createAndStart.complete(claimed);
|
||||
},
|
||||
recover: confirmed => {
|
||||
const claimed = acceptClaimedIssue(confirmed);
|
||||
taskOverlayHistory.leave();
|
||||
refreshMyWorkView();
|
||||
openRoutedWork(claimed, qs('#open-palette'));
|
||||
},
|
||||
announce: message => {
|
||||
qs('#search-preview-status').textContent = message;
|
||||
qs('#my-work-action-status').textContent = message;
|
||||
},
|
||||
});
|
||||
const mobileSearchViewport = createMobileSearchViewport({
|
||||
palette: qs('#cmd-palette'),
|
||||
results: qs('#cmd-results'),
|
||||
@@ -2652,11 +2853,16 @@
|
||||
mobileSearchViewport.close();
|
||||
qs('#open-palette').focus();
|
||||
}
|
||||
if (previous === 'plan-today' && kind !== 'plan-today') closePlanToday(false);
|
||||
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
|
||||
if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
|
||||
else planTodayPreview.close();
|
||||
addPlanPreviewOnReturn = false;
|
||||
}
|
||||
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
|
||||
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
|
||||
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
|
||||
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
|
||||
if (kind === 'plan-today' && previous !== 'plan-today') openPlanToday(planTodayTrigger, false);
|
||||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||
},
|
||||
});
|
||||
taskOverlayHistory.start();
|
||||
@@ -2754,6 +2960,16 @@
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
|
||||
}
|
||||
});
|
||||
qs('#start-search-result').addEventListener('click', async () => {
|
||||
const detail = searchPreviewDetail;
|
||||
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
|
||||
(!detail.claimable && !detail.assigned_to_me)) return;
|
||||
try {
|
||||
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
|
||||
} catch (error) {
|
||||
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.';
|
||||
}
|
||||
});
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
qs('#find-work').addEventListener('click', openFindWorkSheet);
|
||||
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
|
||||
@@ -3310,8 +3526,14 @@
|
||||
lastMyWork = lastMyWork.filter(item =>
|
||||
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
|
||||
);
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
||||
refreshMyWorkView({ reconcileSession:false });
|
||||
const continuingSession = workSession.active();
|
||||
if (workSession.active()) workSession.complete();
|
||||
if (continuingSession && workSession.active()) {
|
||||
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
|
||||
} else if (!continuingSession) {
|
||||
qs('#my-work-action-status').textContent = merging.key + ' merged.';
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
|
||||
button.disabled = false;
|
||||
|
||||
+14
-2
@@ -196,6 +196,11 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
|
||||
<button id="back-to-plan" type="button">Back to plan</button>
|
||||
<button id="add-plan-preview" type="button">Add to Today & back</button>
|
||||
</div>
|
||||
|
||||
<div id="cmd-palette" role="dialog" aria-label="Command palette">
|
||||
<div class="cmd-palette-header">
|
||||
<strong>Search work</strong>
|
||||
@@ -218,7 +223,10 @@
|
||||
<div id="search-preview-meta" class="row"></div>
|
||||
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
|
||||
<div class="search-preview-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<div class="search-preview-primary-actions">
|
||||
<button id="claim-search-result" type="button" hidden>Assign to me</button>
|
||||
<button id="start-search-result" type="button" hidden>Assign & start</button>
|
||||
</div>
|
||||
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -562,7 +570,7 @@
|
||||
</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">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <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>
|
||||
@@ -589,6 +597,7 @@
|
||||
<script src="static/card-planning.js"></script>
|
||||
<script src="static/today-work.js"></script>
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
@@ -600,12 +609,15 @@
|
||||
<script src="static/issue-sheet.js"></script>
|
||||
<script src="static/create-issue-sheet.js"></script>
|
||||
<script src="static/create-and-start.js"></script>
|
||||
<script src="static/assign-and-start.js"></script>
|
||||
<script src="static/queue-today.js"></script>
|
||||
<script src="static/pull-sheet.js"></script>
|
||||
<script src="static/review-sheet.js"></script>
|
||||
<script src="static/work-route.js"></script>
|
||||
<script src="static/task-overlay-history.js"></script>
|
||||
<script src="static/context-poller.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const overlays = options.overlays || [];
|
||||
let launcher = null;
|
||||
let wasHidden = false;
|
||||
let workMode = 'work';
|
||||
|
||||
function select(name) {
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
@@ -34,9 +35,17 @@
|
||||
refreshVisibility();
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
function updateWork(mode, count) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
workMode = labels[mode] ? mode : 'work';
|
||||
const total = Math.max(0, Number(count) || 0);
|
||||
const badge = options.attentionBadge;
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (badge) {
|
||||
badge.textContent = String(total);
|
||||
badge.hidden = total === 0;
|
||||
@@ -44,10 +53,14 @@
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute(
|
||||
'aria-label',
|
||||
total ? 'Work, ' + total + ' items need attention' : 'Work'
|
||||
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention};
|
||||
function updateAttention(count) {
|
||||
updateWork(workMode, count);
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileWorkEntry = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
function open() {
|
||||
const current = mode();
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else options.openFallback();
|
||||
return current;
|
||||
}
|
||||
|
||||
return {mode, open};
|
||||
});
|
||||
@@ -563,8 +563,19 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: () => running && durable,
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
reopen() {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
|
||||
if (index < 0) return false;
|
||||
currentIndex = index;
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume() {
|
||||
const saved = checkpoint?.read();
|
||||
if (!saved) return false;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.createPlanTodayPreview = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
return function createPlanTodayPreview({
|
||||
planner,
|
||||
identity,
|
||||
getScroll = () => 0,
|
||||
setScroll = () => {},
|
||||
onOpen = () => {},
|
||||
onClose = () => {},
|
||||
}) {
|
||||
let current = null;
|
||||
|
||||
function open(item, trigger = null) {
|
||||
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
|
||||
current = { item, trigger, scroll:Number(getScroll()) || 0 };
|
||||
onOpen(item, trigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
function close({ add = false } = {}) {
|
||||
if (!current) return 'closed';
|
||||
const { item, trigger, scroll } = current;
|
||||
let result = 'returned';
|
||||
if (add) {
|
||||
const id = identity(item);
|
||||
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
|
||||
}
|
||||
current = null;
|
||||
setScroll(scroll);
|
||||
onClose(item, trigger);
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
|
||||
}
|
||||
|
||||
return { open, close, snapshot };
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
|
||||
return function queueToday(issue) {
|
||||
const added = todayWork.add(issue);
|
||||
if (added !== 'added' && added !== 'exists') return added;
|
||||
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
|
||||
refresh();
|
||||
warm();
|
||||
return 'sync-unavailable';
|
||||
}
|
||||
refresh();
|
||||
if (added === 'added') todaySync.flush();
|
||||
warm();
|
||||
return 'queued';
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v64';
|
||||
const CACHE = 'stackchain-dashboard-shell-v67';
|
||||
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;
|
||||
@@ -28,6 +28,7 @@ const SHELL = [
|
||||
BASE + 'static/card-planning.js',
|
||||
BASE + 'static/today-work.js',
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
@@ -39,12 +40,15 @@ const SHELL = [
|
||||
BASE + 'static/issue-sheet.js',
|
||||
BASE + 'static/create-issue-sheet.js',
|
||||
BASE + 'static/create-and-start.js',
|
||||
BASE + 'static/assign-and-start.js',
|
||||
BASE + 'static/queue-today.js',
|
||||
BASE + 'static/pull-sheet.js',
|
||||
BASE + 'static/review-sheet.js',
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
'use strict';
|
||||
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today']);
|
||||
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
|
||||
|
||||
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
|
||||
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.middleware.gzip import GZipResponder, IdentityResponder
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
|
||||
def _quality(accept_encoding: str, coding: str) -> float:
|
||||
qualities: dict[str, float] = {}
|
||||
for entry in accept_encoding.split(","):
|
||||
parts = [part.strip() for part in entry.split(";")]
|
||||
name = parts[0].lower()
|
||||
if not name:
|
||||
continue
|
||||
quality = 1.0
|
||||
for parameter in parts[1:]:
|
||||
key, separator, value = parameter.partition("=")
|
||||
if separator and key.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
qualities[name] = quality
|
||||
return qualities.get(coding, qualities.get("*", 0.0))
|
||||
|
||||
|
||||
class NegotiatedGZipMiddleware:
|
||||
"""Compress substantial responses only when the client permits gzip."""
|
||||
|
||||
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
|
||||
self.app = app
|
||||
self.minimum_size = minimum_size
|
||||
self.compresslevel = compresslevel
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
accepted = Headers(scope=scope).get("Accept-Encoding", "")
|
||||
if _quality(accepted, "gzip") > 0:
|
||||
responder: ASGIApp = GZipResponder(
|
||||
self.app, self.minimum_size, compresslevel=self.compresslevel
|
||||
)
|
||||
else:
|
||||
responder = IdentityResponder(self.app, self.minimum_size)
|
||||
await responder(scope, receive, send)
|
||||
@@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
||||
|
||||
from src import dashboard_auth, gitea_proxy
|
||||
from src.compression import NegotiatedGZipMiddleware
|
||||
from src.gitea_proxy import (
|
||||
activity_events,
|
||||
current_user,
|
||||
@@ -67,6 +68,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
|
||||
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
|
||||
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
|
||||
CONTEXT_TIMEOUT_SECONDS = 5.0
|
||||
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
|
||||
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
|
||||
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const first=flow.run(issue);
|
||||
const second=flow.run(issue);
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"calls": [
|
||||
"claim:411",
|
||||
"start:411",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
"results": ["started", "started"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_full_today_blocks_before_assignment_and_explains_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>false,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
|
||||
start:()=>calls.push('start'),
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "full",
|
||||
"calls": ["announce:Today is full—remove an item before assigning this issue."],
|
||||
}
|
||||
|
||||
|
||||
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
|
||||
"recover:411",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_failed_assignment_does_not_start_today_and_can_be_retried():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];let attempt=0;
|
||||
const issue={{repository:'stackchain/dashboard',number:411}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
|
||||
start:()=>{{calls.push('start');return 'started';}},
|
||||
recover:()=>calls.push('recover'),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue).catch(error=>{{
|
||||
calls.push('error:'+error.message);
|
||||
return flow.run(issue);
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"claim:1",
|
||||
"error:conflict",
|
||||
"claim:2",
|
||||
"start",
|
||||
"announce:Assigned, added to Today, and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_already_owned_search_result_starts_without_claiming():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{alreadyOwned:true}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "started",
|
||||
"calls": [
|
||||
"start:413",
|
||||
"announce:Added to Today and ready to work.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_claims_once_without_starting_the_active_session():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
let release;
|
||||
const claimResult=new Promise(resolve=>{{release=resolve;}});
|
||||
const issue={{repository:'stackchain/dashboard',number:415}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
|
||||
start:item=>{{calls.push('start:'+item.number);return 'started';}},
|
||||
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
const first=flow.run(issue, {{destination:'queue'}});
|
||||
const second=flow.run(issue, {{destination:'queue'}});
|
||||
release({{...issue,assignees:['timmy']}});
|
||||
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
|
||||
calls,results,same:first===second
|
||||
}})));
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"calls": [
|
||||
"claim:415",
|
||||
"queue:415",
|
||||
"announce:Assigned and queued in Today. Keep finding work when ready.",
|
||||
],
|
||||
"results": ["queued", "queued"],
|
||||
"same": True,
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
|
||||
script = f"""
|
||||
const fs=require('fs');
|
||||
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
|
||||
process.stdout.write(JSON.stringify({{available:false}}));
|
||||
}} else {{
|
||||
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
|
||||
const calls=[];
|
||||
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
|
||||
const queue=createQueueToday({{
|
||||
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
|
||||
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
|
||||
refresh:()=>calls.push('refresh'),
|
||||
warm:()=>calls.push('warm'),
|
||||
}});
|
||||
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
|
||||
}}
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"available": True,
|
||||
"result": "queued",
|
||||
"calls": [
|
||||
"add",
|
||||
"add:issue:stackchain/dashboard:415:",
|
||||
"refresh",
|
||||
"flush",
|
||||
"warm",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
|
||||
script = f"""
|
||||
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
|
||||
const calls=[];
|
||||
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
|
||||
const flow=createAssignAndStart({{
|
||||
available:()=>true,
|
||||
claim:()=>Promise.resolve(issue),
|
||||
start:()=> 'started',
|
||||
queue:()=> 'sync-unavailable',
|
||||
recover:item=>calls.push('recover:'+item.number),
|
||||
announce:message=>calls.push('announce:'+message),
|
||||
}});
|
||||
flow.run(issue, {{destination:'queue'}}).then(result=>
|
||||
process.stdout.write(JSON.stringify({{result,calls}}))
|
||||
);
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"result": "recovery",
|
||||
"calls": [
|
||||
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
|
||||
"recover:415",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
|
||||
html = HTML.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
worker = WORKER.read_text()
|
||||
|
||||
assert "data-claim-start-index" in dashboard
|
||||
assert "data-claim-queue-index" in dashboard
|
||||
assert ">Start now</button>" in dashboard
|
||||
assert ">Queue Today</button>" in dashboard
|
||||
assert "const assignAndStart = createAssignAndStart({" in dashboard
|
||||
assert "assignAndStart.run(item)" in dashboard
|
||||
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
|
||||
assert "queue: confirmed =>" in dashboard
|
||||
assert "const queueToday = createQueueToday({" in dashboard
|
||||
assert "return queueToday(claimed)" in dashboard
|
||||
assert "createAndStart.available" in dashboard
|
||||
assert "createAndStart.complete" in dashboard
|
||||
assert ".find-work-claim-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
assert ".find-work-claim-actions [data-claim-start-index]" in css
|
||||
assert "grid-column:1 / -1" in css
|
||||
assert "BASE + 'static/assign-and-start.js'" in worker
|
||||
assert "BASE + 'static/queue-today.js'" in worker
|
||||
assert '<script src="static/queue-today.js"></script>' in html
|
||||
@@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
|
||||
assert "openPreviewIssueInMyWork" in html
|
||||
|
||||
|
||||
def test_search_preview_offers_assign_and_start_for_eligible_issues():
|
||||
html = dashboard_bundle_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="start-search-result"' in html
|
||||
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
|
||||
assert "const searchAssignAndStart = createAssignAndStart({" in html
|
||||
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
|
||||
assert ".search-preview-primary-actions" in css
|
||||
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
|
||||
|
||||
|
||||
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
|
||||
script = f"""
|
||||
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
|
||||
|
||||
@@ -3,8 +3,10 @@ import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.frontend_bundle import build_frontend
|
||||
from src.views import dashboard, runtime_bundle, service_worker
|
||||
|
||||
@@ -37,6 +39,62 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
|
||||
assert changed.runtime_bytes != first.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": "gzip"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
assert response.headers["vary"] == "Accept-Encoding"
|
||||
assert int(response.headers["content-length"]) <= 100 * 1024
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
|
||||
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
f"/{main.FRONTEND_BUILD.runtime_name}",
|
||||
headers={"Accept-Encoding": accept_encoding},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "content-encoding" not in response.headers
|
||||
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
|
||||
assert response.content == main.FRONTEND_BUILD.runtime_bytes
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
|
||||
stylesheet = await client.get(
|
||||
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
|
||||
)
|
||||
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert page.headers["content-encoding"] == "gzip"
|
||||
assert page.headers["cache-control"] == "no-cache"
|
||||
assert page.headers["content-security-policy"]
|
||||
assert main.FRONTEND_BUILD.runtime_name in page.text
|
||||
assert stylesheet.headers["content-encoding"] == "gzip"
|
||||
assert "mobile-task-dock" in stylesheet.text
|
||||
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
|
||||
assert "content-encoding" not in health.headers
|
||||
|
||||
|
||||
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
|
||||
build = build_frontend(FRONTEND)
|
||||
|
||||
|
||||
@@ -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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" 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-v64" in worker
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
@@ -35,4 +35,4 @@ 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-v64" in worker
|
||||
assert "stackchain-dashboard-shell-v67" in worker
|
||||
@@ -8,6 +8,39 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2}};
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
}});
|
||||
const modes = [];
|
||||
modes.push(entry.open());
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.resumable = false; modes.push(entry.open());
|
||||
state.today = 0; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "work"],
|
||||
"calls": ["continue", "resume", "start", "fallback"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
|
||||
const workLabel = {{ textContent:'' }};
|
||||
const badge = {{ textContent:'', hidden:true }};
|
||||
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
|
||||
dock.updateWork('continue', 3);
|
||||
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"continuing": {
|
||||
"text": "Continue",
|
||||
"count": "3",
|
||||
"hidden": False,
|
||||
"label": "Continue Today, 3 items need attention",
|
||||
},
|
||||
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -95,6 +165,24 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mobile-work-label">Work</span>' in html
|
||||
assert '<script src="static/mobile-work-entry.js"></script>' in html
|
||||
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "isTodayResumable: () => workSession.resumable()" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "continueToday: continueTodaySession" in html
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
assert "workSession.reopen()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
@@ -112,12 +200,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
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: openMobileWork" in html
|
||||
assert "counts.attention ? 'attention' : 'all'" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "const filter = counts.attention ? 'attention' : 'all';" in html
|
||||
assert "qs('[data-work-filter=\"' + filter + '\"]').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 "mobileTaskDock.updateAttention(counts.attention)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
|
||||
@@ -1428,6 +1428,49 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
|
||||
assert json.loads(result.stdout) == {"finished": 1, "active": False}
|
||||
|
||||
|
||||
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let saves = 0;
|
||||
const checkpoint = {{
|
||||
read: () => null,
|
||||
save: () => {{ saves += 1; return true; }},
|
||||
clear: () => true,
|
||||
}};
|
||||
const items = [1, 2].map(number => ({{
|
||||
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
|
||||
}}));
|
||||
const opened = [];
|
||||
const progress = [];
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems: () => items, getFilter: () => 'all', checkpoint,
|
||||
onOpen: item => opened.push(item.number),
|
||||
onProgress: state => progress.push(state.index), onFinish: () => {{}},
|
||||
}});
|
||||
session.start();
|
||||
session.next();
|
||||
const savesBeforeReopen = saves;
|
||||
const reopened = session.reopen();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
|
||||
todayActive:session.checkpointed(),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"reopened": True,
|
||||
"opened": [1, 2, 2],
|
||||
"progress": [1, 2, 2],
|
||||
"savesBeforeReopen": 2,
|
||||
"savesAfterReopen": 2,
|
||||
"todayActive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_today_session_checkpoint_resumes_current_item_after_reload():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -1643,6 +1686,56 @@ async def test_closing_issue_advances_active_session_once_and_exposes_close_and_
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
|
||||
html = await dashboard()
|
||||
|
||||
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
|
||||
assert "const continuingSession = workSession.active();" in merge_handler
|
||||
assert "if (workSession.active()) workSession.complete();" in merge_handler
|
||||
assert "merged. Next work item opened." in merge_handler
|
||||
assert merge_handler.index("await pullController.merge") < merge_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
|
||||
"if (workSession.active()) workSession.complete();"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
|
||||
assert "else if (!continuingSession)" in merge_handler
|
||||
assert "merging.key + ' merged.'" in merge_handler
|
||||
assert "else if (continuingSession" not in merge_handler
|
||||
assert "onFinish: () =>" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
|
||||
html = await dashboard()
|
||||
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
|
||||
"qs('#keep-update-unread').addEventListener", 1
|
||||
)[0]
|
||||
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
|
||||
|
||||
assert "lastMyWork = lastMyWork.filter" in success_path
|
||||
assert "workSession.complete()" in success_path
|
||||
assert "lastMyWork = lastMyWork.filter" not in failure_path
|
||||
assert "workSession.complete()" not in failure_path
|
||||
assert "The pull request remains in My Work; refresh and retry." in failure_path
|
||||
assert "button.disabled = false;" in failure_path
|
||||
|
||||
|
||||
def test_work_session_can_start_at_a_newly_created_item():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
|
||||
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
|
||||
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
|
||||
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
|
||||
|
||||
|
||||
@@ -78,6 +79,53 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
|
||||
script = f"""
|
||||
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
|
||||
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
|
||||
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
|
||||
const events = [];
|
||||
let scroll = 318;
|
||||
const planner = createPlanToday({{
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
save: () => true,
|
||||
}});
|
||||
planner.open([item(1)], [item(1), item(2)]);
|
||||
const preview = createPlanTodayPreview({{
|
||||
planner,
|
||||
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
|
||||
getScroll: () => scroll,
|
||||
setScroll: value => {{ scroll = value; }},
|
||||
onOpen: value => events.push(['open', value.number]),
|
||||
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
|
||||
}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const first = preview.close({{add:true}});
|
||||
preview.open(item(2), 'preview-2');
|
||||
scroll = 0;
|
||||
const second = preview.close({{add:true}});
|
||||
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"first": "added",
|
||||
"second": "already-added",
|
||||
"scroll": 318,
|
||||
"events": [
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
["open", 2],
|
||||
["close", 2, "preview-2"],
|
||||
],
|
||||
"planner": {
|
||||
"open": True,
|
||||
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
|
||||
"count": 2,
|
||||
"limit": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
html = await dashboard()
|
||||
@@ -96,6 +144,13 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
|
||||
assert ".plan-today-item { grid-template-columns:1fr; }" in html
|
||||
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
|
||||
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
|
||||
assert '<script src="static/plan-today-preview.js"></script>' in html
|
||||
assert 'data-plan-preview="' in html
|
||||
assert 'id="add-plan-preview"' in html
|
||||
assert "taskOverlayHistory.open('plan-today-preview')" in html
|
||||
assert "planTodayPreview.close({ add:true })" in html
|
||||
assert "Preview unavailable offline" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -113,5 +168,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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -121,7 +121,7 @@ async function dispatchNotificationClick(route) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -130,7 +130,7 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -138,14 +138,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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" 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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -154,21 +154,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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" 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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" 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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -331,6 +331,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
assert "batch: withSessionCsrf" in source
|
||||
|
||||
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -365,6 +372,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/card-planning.js",
|
||||
"/dashboard/static/today-work.js",
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
@@ -376,12 +384,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/issue-sheet.js",
|
||||
"/dashboard/static/create-issue-sheet.js",
|
||||
"/dashboard/static/create-and-start.js",
|
||||
"/dashboard/static/assign-and-start.js",
|
||||
"/dashboard/static/queue-today.js",
|
||||
"/dashboard/static/pull-sheet.js",
|
||||
"/dashboard/static/review-sheet.js",
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
|
||||
@@ -162,6 +162,44 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_plan_today_preview_is_a_nested_browser_history_layer():
|
||||
script = f"""
|
||||
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
|
||||
const listeners = {{}};
|
||||
const changes = [];
|
||||
const stack = [{{ page:'dashboard' }}];
|
||||
let cursor = 0;
|
||||
const history = {{
|
||||
get state() {{ return stack[cursor]; }},
|
||||
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
|
||||
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
|
||||
}};
|
||||
const controller = createTaskOverlayHistory({{
|
||||
history,
|
||||
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
|
||||
onChange(kind, previous) {{ changes.push([kind, previous]); }},
|
||||
}});
|
||||
controller.start();
|
||||
controller.open('plan-today');
|
||||
const previewed = controller.open('plan-today-preview');
|
||||
controller.close();
|
||||
process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"previewed": True,
|
||||
"cursor": 1,
|
||||
"current": "plan-today",
|
||||
"changes": [
|
||||
["plan-today", None],
|
||||
["plan-today-preview", "plan-today"],
|
||||
["plan-today", "plan-today-preview"],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
|
||||
html = dashboard_bundle_text()
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||
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-v64" in source
|
||||
assert "stackchain-dashboard-shell-v67" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -161,7 +161,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert 'data-work-filter="today"' in html
|
||||
assert 'data-work-count="today"' in html
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
|
||||
Reference in new issue
Block a user