Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679639394f | ||
|
|
3b374cbb50 | ||
|
|
ad5e50eb48 | ||
|
|
4b8d0c4504 | ||
|
|
6e3b6804c1 | ||
|
|
c31129f1cc | ||
|
|
4afe4dd3b9 | ||
|
|
985472eb06 | ||
|
|
8b36455c21 | ||
|
|
3ba1fe76f0 | ||
|
|
321474e8cf | ||
|
|
166d14dd35 | ||
|
|
7a62f7ead7 | ||
|
|
461f90c8ff | ||
|
|
9ea590a65c | ||
|
|
33a6d1bb6d |
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;
|
||||
@@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
let duplicateRequest = 0;
|
||||
let duplicateState = {status: 'idle', key: '', candidates: []};
|
||||
let acknowledgedDuplicateKey = '';
|
||||
const repositoryPageRequests = new Map();
|
||||
const safeLabelIds = value => Array.from(new Set(
|
||||
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
|
||||
)).slice(0, 20);
|
||||
@@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
);
|
||||
}
|
||||
|
||||
function loadRepositoryPage(page) {
|
||||
const safePage = Math.max(1, Math.floor(Number(page) || 1));
|
||||
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
|
||||
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
|
||||
.then(payload => ({
|
||||
items: Array.isArray(payload?.items) ? payload.items : [],
|
||||
page: Number(payload?.page) || safePage,
|
||||
total: Math.max(0, Number(payload?.total) || 0),
|
||||
has_more: payload?.has_more === true,
|
||||
}))
|
||||
.finally(() => repositoryPageRequests.delete(safePage));
|
||||
repositoryPageRequests.set(safePage, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
function duplicateKey(draft) {
|
||||
const repository = String(draft?.repository || '').trim();
|
||||
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
|
||||
}
|
||||
|
||||
return {
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
|
||||
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
|
||||
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
|
||||
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
@@ -289,6 +296,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-form label { display:grid; gap:6px; }
|
||||
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
|
||||
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
|
||||
@@ -364,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); }
|
||||
|
||||
+291
-23
@@ -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) {
|
||||
@@ -1868,6 +2042,27 @@
|
||||
};
|
||||
}
|
||||
|
||||
let issueCaptureRepositories = [];
|
||||
let nextIssueRepositoryPage = 2;
|
||||
let moreIssueRepositoriesAvailable = false;
|
||||
|
||||
function appendIssueRepositories(items) {
|
||||
const select = qs('#create-issue-repository');
|
||||
const selected = select.value;
|
||||
const known = new Set(issueCaptureRepositories);
|
||||
(Array.isArray(items) ? items : []).forEach(item => {
|
||||
const repository = String(item?.full_name || '').trim();
|
||||
if (!repository || known.has(repository)) return;
|
||||
known.add(repository);
|
||||
issueCaptureRepositories.push(repository);
|
||||
const option = document.createElement('option');
|
||||
option.value = repository;
|
||||
option.textContent = repository;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (selected) select.value = selected;
|
||||
}
|
||||
|
||||
let duplicateCheckTimer = null;
|
||||
function renderIssueDuplicates(state) {
|
||||
if (state.status === 'stale') return;
|
||||
@@ -1990,13 +2185,18 @@
|
||||
return;
|
||||
}
|
||||
const captureDraft = issueCapture.loadDraft();
|
||||
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
|
||||
repositories.unshift(captureDraft.repository);
|
||||
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
|
||||
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
|
||||
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
|
||||
issueCaptureRepositories.unshift(captureDraft.repository);
|
||||
}
|
||||
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
|
||||
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
|
||||
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
|
||||
).join('');
|
||||
if (nextIssueRepositoryPage === 2) {
|
||||
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
|
||||
}
|
||||
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
|
||||
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
|
||||
qs('#create-issue-title').value = captureDraft.title;
|
||||
qs('#create-issue-body').value = captureDraft.body;
|
||||
@@ -2004,9 +2204,9 @@
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
scheduleIssueDuplicateCheck();
|
||||
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#submit-new-issue').disabled = !repositories.length;
|
||||
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
|
||||
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
|
||||
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
|
||||
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
qs('#create-issue-title').focus();
|
||||
@@ -2475,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;
|
||||
@@ -2482,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;
|
||||
@@ -2511,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.' :
|
||||
@@ -2525,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'),
|
||||
@@ -2626,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();
|
||||
@@ -2728,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);
|
||||
@@ -2799,6 +3041,26 @@
|
||||
saveIssueCaptureDraft();
|
||||
scheduleIssueDuplicateCheck();
|
||||
});
|
||||
qs('#load-more-issue-repositories').addEventListener('click', async event => {
|
||||
const button = event.currentTarget;
|
||||
const status = qs('#create-issue-repository-status');
|
||||
saveIssueCaptureDraft();
|
||||
button.disabled = true;
|
||||
status.textContent = 'Loading more repositories…';
|
||||
try {
|
||||
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
|
||||
appendIssueRepositories(payload.items);
|
||||
nextIssueRepositoryPage = payload.page + 1;
|
||||
moreIssueRepositoriesAvailable = payload.has_more;
|
||||
button.hidden = !moreIssueRepositoriesAvailable;
|
||||
status.textContent = payload.items.length ?
|
||||
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
|
||||
} catch (_error) {
|
||||
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
|
||||
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
|
||||
qs('#create-issue-anyway').addEventListener('click', () => {
|
||||
@@ -3264,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;
|
||||
|
||||
+16
-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>
|
||||
@@ -361,6 +369,8 @@
|
||||
<label for="create-issue-repository">Repository
|
||||
<select id="create-issue-repository" required></select>
|
||||
</label>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
@@ -560,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>
|
||||
@@ -587,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>
|
||||
@@ -598,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-v63';
|
||||
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)
|
||||
+50
-2
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class RepositoryItems(list[dict]):
|
||||
"""A list-compatible repository page carrying truthful pagination metadata."""
|
||||
|
||||
def __init__(self, items: list[dict], pagination: dict):
|
||||
super().__init__(items)
|
||||
self.pagination = pagination
|
||||
|
||||
|
||||
class WorkRouteUnavailableError(ValueError):
|
||||
"""Raised when a shared route no longer belongs in the current user's queue."""
|
||||
|
||||
@@ -261,8 +269,48 @@ async def current_user() -> dict:
|
||||
return await fetch("user")
|
||||
|
||||
|
||||
async def repos() -> list[dict]:
|
||||
return await fetch("user/repos?limit=50")
|
||||
async def repo_page(page: int = 1, limit: int = 50) -> dict:
|
||||
"""Load one bounded page of repositories available to the current user."""
|
||||
response = await _get_client().get(
|
||||
"/api/v1/user/repos",
|
||||
headers=_auth(),
|
||||
params={"page": page, "limit": limit},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("Gitea repository response was not a list")
|
||||
items = [item for item in payload if isinstance(item, dict)]
|
||||
try:
|
||||
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
|
||||
except (TypeError, ValueError):
|
||||
total = len(items)
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"total": total,
|
||||
"has_more": page * limit < total,
|
||||
}
|
||||
|
||||
|
||||
async def repos() -> RepositoryItems:
|
||||
result = await repo_page()
|
||||
return RepositoryItems(
|
||||
result["items"],
|
||||
{key: result[key] for key in ("page", "total", "has_more")},
|
||||
)
|
||||
|
||||
|
||||
async def repository_access(repository: str) -> dict | None:
|
||||
"""Return a repository only when the authenticated user can access it."""
|
||||
response = await _get_client().get(
|
||||
f"/api/v1/repos/{repository}", headers=_auth()
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
WORK_SEARCHES = {
|
||||
|
||||
+40
-22
@@ -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
|
||||
@@ -502,6 +504,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
|
||||
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
|
||||
]
|
||||
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
|
||||
repository_pagination = getattr(repo_data, "pagination", None)
|
||||
if isinstance(repository_pagination, dict):
|
||||
payload["repository_pagination"] = repository_pagination
|
||||
pagination = {}
|
||||
pagination.update(getattr(issues_data, "pagination", {}))
|
||||
pagination.update(getattr(prs_data, "pagination", {}))
|
||||
@@ -1213,6 +1218,36 @@ async def context() -> JSONResponse:
|
||||
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
|
||||
|
||||
|
||||
@app.get("/api/v1/repositories")
|
||||
async def repository_page(
|
||||
page: int = Query(default=1, ge=1, le=1000),
|
||||
limit: int = Query(default=50, ge=1, le=50),
|
||||
) -> JSONResponse:
|
||||
"""Return one bounded repository page for lazy issue-capture selection."""
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
gitea_proxy.repo_page(page=page, limit=limit),
|
||||
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
|
||||
)
|
||||
items = [
|
||||
Repo(
|
||||
id=item["id"], name=item["name"], full_name=item["full_name"],
|
||||
description=item.get("description") or "", url=item["html_url"],
|
||||
updated_at=item.get("updated_at", ""),
|
||||
).model_dump()
|
||||
for item in result.get("items", [])
|
||||
if isinstance(item, dict)
|
||||
and all(field in item for field in ("id", "name", "full_name", "html_url"))
|
||||
]
|
||||
return JSONResponse({**result, "items": items})
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Repositories could not be loaded. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/background-identity")
|
||||
async def background_identity() -> JSONResponse:
|
||||
"""Return only the account key required to safely drain a browser outbox."""
|
||||
@@ -2191,13 +2226,7 @@ async def repository_milestones(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_milestones():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_milestones(repository)
|
||||
|
||||
@@ -2220,13 +2249,7 @@ async def repository_labels(owner: str, repo: str):
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def load_labels():
|
||||
available = await gitea_proxy.repos()
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if repository not in accessible:
|
||||
if await gitea_proxy.repository_access(repository) is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
return await gitea_proxy.repo_labels(repository)
|
||||
|
||||
@@ -2252,16 +2275,11 @@ async def create_assigned_issue(
|
||||
repository = f"{owner}/{repo}"
|
||||
|
||||
async def create_issue():
|
||||
user, available = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repos()
|
||||
user, accessible = await asyncio.gather(
|
||||
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
|
||||
)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
accessible = {
|
||||
item.get("full_name")
|
||||
for item in (available if isinstance(available, list) else [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if not login or repository not in accessible:
|
||||
if not login or accessible is None:
|
||||
raise HTTPException(status_code=404, detail="Repository not found")
|
||||
if creation.label_ids:
|
||||
available_labels = await gitea_proxy.repo_labels(repository)
|
||||
|
||||
@@ -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))});
|
||||
|
||||
@@ -195,7 +195,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
|
||||
assert 'Discard this unfinished draft?' in html
|
||||
assert '.draft-actions button { min-height:44px;' in html
|
||||
assert 'createDraftInbox({ storage: localStorage' in html
|
||||
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
|
||||
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
|
||||
assert "Verified not posted — retry" in html
|
||||
assert "payload.detail?.message" in html
|
||||
assert "error.code = payload.detail?.code" in html
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
|
||||
from src.idempotency import IdempotencyLedger
|
||||
|
||||
|
||||
async def repository_access_from(loader, repository):
|
||||
repositories = await loader()
|
||||
return next(
|
||||
(item for item in repositories if item.get("full_name") == repository), None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
|
||||
requests = []
|
||||
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
|
||||
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
|
||||
calls = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append((request.url.path, dict(request.url.params)))
|
||||
if request.url.path.endswith("/user/repos"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
headers={"X-Total-Count": "51"},
|
||||
)
|
||||
if request.url.path.endswith("/repos/stackchain/later"):
|
||||
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
page = await gitea_proxy.repo_page(page=2, limit=50)
|
||||
first_page = await gitea_proxy.repos()
|
||||
accessible = await gitea_proxy.repository_access("stackchain/later")
|
||||
missing = await gitea_proxy.repository_access("stackchain/missing")
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert page == {
|
||||
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
|
||||
"page": 2,
|
||||
"total": 51,
|
||||
"has_more": False,
|
||||
}
|
||||
assert accessible["full_name"] == "stackchain/later"
|
||||
assert missing is None
|
||||
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
|
||||
assert calls == [
|
||||
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
|
||||
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
|
||||
("/api/v1/repos/stackchain/later", {}),
|
||||
("/api/v1/repos/stackchain/missing", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
|
||||
access_calls = []
|
||||
create_calls = []
|
||||
|
||||
async def page(page, limit):
|
||||
assert (page, limit) == (2, 50)
|
||||
return {
|
||||
"items": [{
|
||||
"id": 51, "name": "later", "full_name": "stackchain/later",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/later",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
"page": 2, "total": 51, "has_more": False,
|
||||
}
|
||||
|
||||
async def access(repository):
|
||||
access_calls.append(repository)
|
||||
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
|
||||
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
async def create(repository, title, body, assignee, label_ids):
|
||||
create_calls.append((repository, title, assignee))
|
||||
return {
|
||||
"id": 403, "number": 403, "title": title, "state": "open",
|
||||
"repository": repository, "labels": [], "assignees": [assignee],
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
"url": "https://forge.example/stackchain/later/issues/403",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
listed = await client.get("/api/v1/repositories?page=2&limit=50")
|
||||
created = await client.post(
|
||||
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
|
||||
)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
|
||||
assert listed.json()["has_more"] is False
|
||||
assert created.status_code == 201
|
||||
assert access_calls == ["stackchain/later"]
|
||||
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
|
||||
|
||||
|
||||
def test_context_exposes_truthful_repository_pagination():
|
||||
repositories = gitea_proxy.RepositoryItems(
|
||||
[{
|
||||
"id": 1, "name": "api", "full_name": "stackchain/api",
|
||||
"description": "", "html_url": "https://forge.example/stackchain/api",
|
||||
"updated_at": "2026-08-09T12:00:00Z",
|
||||
}],
|
||||
{"page": 1, "total": 51, "has_more": True},
|
||||
)
|
||||
|
||||
payload = main._context_payload(
|
||||
{"id": 2, "login": "timmy"}, repositories, [], []
|
||||
)
|
||||
|
||||
assert payload["repository_pagination"] == {
|
||||
"page": 1, "total": 51, "has_more": True
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
|
||||
calls = []
|
||||
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-concurrent"}
|
||||
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "restart-create-201"}
|
||||
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
|
||||
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
|
||||
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
|
||||
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
|
||||
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
|
||||
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
|
||||
monkeypatch.setattr(
|
||||
main.gitea_proxy, "repository_access",
|
||||
lambda repository: repository_access_from(available_repos, repository),
|
||||
)
|
||||
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
|
||||
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -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-v63" 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-v63" 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-v63" 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))});
|
||||
@@ -1938,6 +2031,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
|
||||
assert '.load-more-work { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="load-more-issue-repositories"' in html
|
||||
assert '>Load more repositories<' in html
|
||||
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
|
||||
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
|
||||
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
|
||||
assert 'appendIssueRepositories(payload.items)' in html
|
||||
assert '.create-issue-repository-more { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
|
||||
html = await dashboard()
|
||||
@@ -2040,6 +2146,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
assert output["results"][1]["number"] == 17
|
||||
|
||||
|
||||
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key,value) => values.set(key,value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let calls = 0;
|
||||
let release;
|
||||
const capture = createIssueCapture({{
|
||||
storage,
|
||||
fetchJson: url => {{
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.reject(new Error('temporary'));
|
||||
return new Promise(resolve => {{ release = () => resolve({{
|
||||
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
|
||||
}}); }});
|
||||
}},
|
||||
}});
|
||||
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
|
||||
capture.loadRepositoryPage(2).catch(() => {{
|
||||
const first = capture.loadRepositoryPage(2);
|
||||
const duplicate = capture.loadRepositoryPage(2);
|
||||
release();
|
||||
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
|
||||
calls, results, draft:capture.loadDraft()
|
||||
}})));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["calls"] == 2
|
||||
assert output["results"] == [
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
|
||||
]
|
||||
assert output["draft"] == {
|
||||
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
|
||||
}
|
||||
|
||||
|
||||
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
|
||||
script = f"""
|
||||
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
|
||||
|
||||
@@ -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-v63" 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-v63" 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-v63" 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-v63" 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-v63" 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-v63" 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-v63" 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-v63" 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-v63" 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