Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdba0554df | ||
|
|
d2a85cd5ea | ||
|
|
1f034a6f64 | ||
|
|
de6d6d0e61 | ||
|
|
8dc3880167 | ||
|
|
b93e63ab70 | ||
|
|
8c82644515 | ||
|
|
6c6cd3c71b | ||
|
|
4f6cebae7c | ||
|
|
341a3283ea | ||
|
|
9c276fa251 | ||
|
|
940f7c05d2 | ||
|
|
d34c082da2 | ||
|
|
1c086be09b | ||
|
|
4cfcb92eec | ||
|
|
b484bbef03 | ||
|
|
623817eb7d | ||
|
|
11347e5fe0 | ||
|
|
bb1712279c | ||
|
|
e7d5196987 | ||
|
|
64ff5127d8 | ||
|
|
4ac5a0f499 | ||
|
|
715df380e0 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createAgendaSessionLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectAgenda();
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(complete => {
|
||||
if (!options.isAgendaSelected()) return 'cancelled';
|
||||
if (complete === false || options.hasMore()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No deadlines are ready in Agenda.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isAgendaSelected()) {
|
||||
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -37,6 +37,8 @@ function createContextPoller({
|
||||
let retainedSnapshot = null;
|
||||
let nextDelayMs = intervalMs;
|
||||
let failureStreak = 0;
|
||||
let nextRetryAt = null;
|
||||
let lastSuccessAt = null;
|
||||
|
||||
function snapshotDelay(snapshot) {
|
||||
const freshness = snapshot && snapshot.freshness;
|
||||
@@ -69,8 +71,10 @@ function createContextPoller({
|
||||
function schedule(delayMs = intervalMs) {
|
||||
cancelTimer();
|
||||
if (stopped || isHidden()) return;
|
||||
nextRetryAt = Date.now() + delayMs;
|
||||
timer = setTimer(() => {
|
||||
timer = null;
|
||||
nextRetryAt = null;
|
||||
refresh();
|
||||
}, delayMs);
|
||||
}
|
||||
@@ -125,6 +129,7 @@ function createContextPoller({
|
||||
.then((snapshot) => {
|
||||
if (activeRequest !== requestState) return retainedSnapshot;
|
||||
failureStreak = 0;
|
||||
lastSuccessAt = Date.now();
|
||||
nextDelayMs = snapshotDelay(snapshot);
|
||||
const changedSections = ['context', 'events', 'notifications'].filter(
|
||||
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
|
||||
@@ -165,6 +170,14 @@ function createContextPoller({
|
||||
start: refresh,
|
||||
refresh,
|
||||
setVisible,
|
||||
getState() {
|
||||
return {
|
||||
refreshing: Boolean(activeRequest),
|
||||
nextRetryAt,
|
||||
lastSuccessAt,
|
||||
failureStreak,
|
||||
};
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelTimer();
|
||||
|
||||
+14
-2
@@ -5,7 +5,17 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
|
||||
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
|
||||
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
|
||||
.live-data-status-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.live-data-status-sheet[hidden] { display:none; }
|
||||
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
|
||||
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
|
||||
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
|
||||
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
|
||||
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
|
||||
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
|
||||
.app-menu { margin-left:auto; }
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
@@ -373,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.update-sheet-header button { min-height:44px; }
|
||||
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
|
||||
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
.update-reply { display:grid; gap:8px; margin-top:16px; }
|
||||
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
|
||||
@@ -390,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.update-ownership-actions button { min-width:0; width:100%; }
|
||||
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
|
||||
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||
.update-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
@@ -605,7 +617,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
|
||||
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
|
||||
+232
-33
@@ -58,26 +58,30 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
getTodayCount: () => todayMyWork.length,
|
||||
getEligibleCount: () => activeMyWork.length,
|
||||
continueToday: continueTodaySession,
|
||||
resumeToday: resumeTodaySession,
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
let mobileQueueCounts = {};
|
||||
const mobileQueueLauncher = createMobileQueueLauncher({
|
||||
openToday: () => mobileWorkEntry.open(),
|
||||
openAgenda: openAgendaSession,
|
||||
openUpdates: openUpdateTriage,
|
||||
selectFilter: selectMobileQueue,
|
||||
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
getCounts: () => mobileQueueCounts,
|
||||
openFindWork: () => qs('#find-work').click(),
|
||||
});
|
||||
const mobileWorkEntry = createMobileWorkEntry({
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
getTodayCount: () => todayMyWork.length,
|
||||
getEligibleCount: () => activeMyWork.length,
|
||||
queueLauncher: mobileQueueLauncher,
|
||||
continueToday: continueTodaySession,
|
||||
resumeToday: resumeTodaySession,
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
|
||||
findWork: () => qs('#find-work').click(),
|
||||
});
|
||||
function showMobileQueueCompletion(completedName) {
|
||||
const next = mobileQueueLauncher.recommend();
|
||||
qs('#mobile-queue-heading').textContent = completedName + ' cleared';
|
||||
@@ -139,9 +143,7 @@
|
||||
}
|
||||
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
|
||||
if (savedMilestone) selectedWorkMilestone = savedMilestone;
|
||||
} catch (e) {
|
||||
console.warn('Filter restore failed', e);
|
||||
}
|
||||
} catch (e) {}
|
||||
let lastMyWork = [];
|
||||
let lastDrafts = [];
|
||||
let lastNotifications = [];
|
||||
@@ -275,7 +277,10 @@
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) interruptionPrompt.background();
|
||||
else interruptionPrompt.foreground();
|
||||
if (!document.hidden) refreshMyWorkView();
|
||||
if (!document.hidden) {
|
||||
refreshMyWorkView();
|
||||
if (updateTriage.active()) updateTriage.reconcile();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchReviewJson(url, options) {
|
||||
@@ -532,11 +537,19 @@
|
||||
}).catch(() => { /* The foreground localStorage outboxes remain available. */ });
|
||||
}
|
||||
const shareParams = new URLSearchParams(location.search);
|
||||
const appShortcut = mobileAppShortcuts.createController({
|
||||
search: location.search,
|
||||
continueWork: () => mobileWorkEntry.open(),
|
||||
newIssue: () => openCreateIssueSheet(),
|
||||
agenda: () => openAgendaSession(),
|
||||
});
|
||||
const sharedLaunch = {
|
||||
title: shareParams.get('title') || '',
|
||||
text: shareParams.get('text') || '',
|
||||
url: shareParams.get('url') || '',
|
||||
};
|
||||
const sharedImageMarker = shareParams.get('shared') || '';
|
||||
let sharedImageHandled = false;
|
||||
const commentActionFeatures = createFeatureLoader({
|
||||
document,
|
||||
urls: {
|
||||
@@ -576,7 +589,7 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
if (Object.values(sharedLaunch).some(Boolean)) await ensureIssueCapture();
|
||||
if (Object.values(sharedLaunch).some(Boolean) || sharedImageMarker) await ensureIssueCapture();
|
||||
const pullWorkflowFeatures = createFeatureLoader({
|
||||
document,
|
||||
urls: {
|
||||
@@ -706,6 +719,18 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function muteNotification(notificationId) {
|
||||
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) +
|
||||
'/mute', { method: 'POST', headers: { Accept: 'application/json' } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = new Error(payload.error || 'Muting future updates failed.');
|
||||
error.muted = payload.muted === true;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function markNotificationsRead(ids) {
|
||||
const response = await fetch('api/v1/notifications/read', {
|
||||
method: 'PATCH',
|
||||
@@ -914,6 +939,7 @@
|
||||
qs('#send-update-reply').disabled = false;
|
||||
qs('#send-update-reply-read-next').disabled = false;
|
||||
qs('#acknowledge-update-next').hidden = true;
|
||||
qs('#mute-update-next').hidden = true;
|
||||
qs('#update-ownership-action').hidden = true;
|
||||
qs('#update-ownership-start').hidden = true;
|
||||
qs('#create-update-follow-up').hidden = true;
|
||||
@@ -929,6 +955,7 @@
|
||||
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
|
||||
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
|
||||
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
|
||||
qs('#mute-update-next').hidden = !detail.mute_supported;
|
||||
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
|
||||
if (offlineWorkMode) {
|
||||
setOfflineUpdateControls(true);
|
||||
@@ -960,6 +987,48 @@
|
||||
onClose: () => closeUpdateSheet(false),
|
||||
});
|
||||
|
||||
const updateTriage = createUpdateTriageSession({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
getItems: () => lastMyWork.filter(item => item?.has_update && Number.isInteger(item.notification_id)),
|
||||
onOpen: item => notificationReader.open(
|
||||
item,
|
||||
offlineWorkMode ? offlineWorkStore.loadDetail(confirmedOwnerLogin, item) : null
|
||||
),
|
||||
onProgress: state => {
|
||||
const progress = qs('#update-triage-progress');
|
||||
progress.hidden = false;
|
||||
progress.textContent = 'Update ' + state.index + ' of ' + state.total;
|
||||
},
|
||||
onFinish: () => {
|
||||
qs('#update-triage-progress').hidden = true;
|
||||
showMobileQueueCompletion('Updates');
|
||||
},
|
||||
});
|
||||
|
||||
const updateTriageLauncher = createUpdateTriageLauncher({
|
||||
selectUpdates: () => selectMobileQueue('update'),
|
||||
discover: () => api('api/v1/notifications/snapshot'),
|
||||
applySnapshot: snapshot => {
|
||||
lastNotifications = snapshot.items || [];
|
||||
notificationPager.reset({page:1, total:snapshot.total || 0, has_more:false});
|
||||
if (lastContextSnapshot) {
|
||||
lastContextSnapshot.notifications = lastNotifications;
|
||||
paintMyWork(lastContextSnapshot);
|
||||
}
|
||||
},
|
||||
isUpdatesSelected: () => selectedWorkFilter === 'update',
|
||||
hasMore: () => Boolean(notificationPagination.has_more),
|
||||
hasCheckpoint: () => updateTriage.resumable(),
|
||||
resume: () => updateTriage.resume(),
|
||||
start: () => updateTriage.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
|
||||
function openUpdateTriage() {
|
||||
return updateTriageLauncher.open();
|
||||
}
|
||||
|
||||
function routedWorkItem(item) {
|
||||
if (item?.has_update && item.kind === 'update') {
|
||||
return { ...item, kind:'update' };
|
||||
@@ -1082,6 +1151,15 @@
|
||||
'Session recovery could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
const agendaSessionCheckpoint = createWorkSessionCheckpoint({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
key: 'stackchain.agenda-session.v1',
|
||||
onError: () => {
|
||||
qs('#my-work-action-status').textContent =
|
||||
'Agenda progress could not be saved on this device. You can keep working.';
|
||||
},
|
||||
});
|
||||
const timer = createTodayTimer({
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
@@ -1150,8 +1228,10 @@
|
||||
const active = workSession.active();
|
||||
const startWorkSession = qs('#start-work-session');
|
||||
startWorkSession.hidden = active;
|
||||
startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work';
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
startWorkSession.textContent = selectedWorkFilter === 'agenda' ?
|
||||
(agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda') : 'Start work';
|
||||
qs('#resume-today-session').hidden = active || selectedWorkFilter !== 'today' ||
|
||||
!todayMyWork.length || !workSession.resumable();
|
||||
qs('#end-today-session').hidden = !active;
|
||||
updateDetailDeferLabels(active);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
@@ -1164,8 +1244,9 @@
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
|
||||
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
|
||||
checkpoint: sessionCheckpoint,
|
||||
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||||
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
|
||||
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
|
||||
checkpointedEnabled: () => selectedWorkFilter === 'today',
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
updateWorkSessionActions();
|
||||
@@ -1186,6 +1267,7 @@
|
||||
updateWorkSessionActions();
|
||||
if (selectedWorkFilter === 'agenda') {
|
||||
closeOpenWorkSheets();
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Agenda complete.';
|
||||
return;
|
||||
}
|
||||
@@ -1196,6 +1278,20 @@
|
||||
qs('[data-work-filter="today"]').click();
|
||||
}
|
||||
|
||||
const agendaSessionLauncher = createAgendaSessionLauncher({
|
||||
selectAgenda: () => selectMobileQueue('agenda'),
|
||||
discover: completeAgendaIssues,
|
||||
isAgendaSelected: () => selectedWorkFilter === 'agenda',
|
||||
hasMore: () => Boolean(workPagination.issue?.has_more),
|
||||
hasCheckpoint: () => Boolean(agendaSessionCheckpoint.read()),
|
||||
resume: () => workSession.resume(),
|
||||
start: () => workSession.start(),
|
||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
function openAgendaSession() {
|
||||
return agendaSessionLauncher.open();
|
||||
}
|
||||
|
||||
let todayReadinessTrigger = null;
|
||||
let todayReadinessBlockerFocus = null;
|
||||
let searchPreviewReturnKind = null;
|
||||
@@ -2144,6 +2240,7 @@
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
qs('#mark-update-read-next').textContent = offline ? 'Queue read & next' : 'Mark read & next';
|
||||
qs('#acknowledge-update-next').disabled = offline;
|
||||
qs('#mute-update-next').disabled = offline;
|
||||
qs('#update-ownership-action').disabled = offline;
|
||||
qs('#update-ownership-start').disabled = offline;
|
||||
qs('#load-older-update-comments').disabled = offline;
|
||||
@@ -2319,17 +2416,20 @@
|
||||
}
|
||||
|
||||
async function completeAgendaIssues() {
|
||||
if (selectedWorkFilter !== 'agenda' || !workPagination.issue?.has_more || !lastContextSnapshot) return;
|
||||
if (selectedWorkFilter !== 'agenda') return false;
|
||||
if (!workPagination.issue?.has_more) return true;
|
||||
if (!lastContextSnapshot) return false;
|
||||
agendaChecking = true;
|
||||
qs('#my-work-action-status').textContent = 'Checking all assigned deadlines…';
|
||||
renderMyWork();
|
||||
const complete = await workPager.loadAll('issue', () => lastContextSnapshot?.issues || []);
|
||||
agendaChecking = false;
|
||||
if (selectedWorkFilter !== 'agenda') return;
|
||||
if (selectedWorkFilter !== 'agenda') return false;
|
||||
qs('#my-work-action-status').textContent = complete ?
|
||||
'All assigned deadlines checked.' :
|
||||
'Agenda check paused. Retry to check older assigned deadlines.';
|
||||
renderMyWork();
|
||||
return complete && !workPagination.issue?.has_more;
|
||||
}
|
||||
|
||||
function renderDrafts() {
|
||||
@@ -3617,6 +3717,20 @@
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
if (!sharedImageHandled && sharedImageMarker) {
|
||||
sharedImageHandled = true;
|
||||
if (createIssueAttachmentController.state()) {
|
||||
qs('#create-issue-attachment-status').textContent = 'Remove the current screenshot before adding the shared screenshot.';
|
||||
} else {
|
||||
await sharedImageCapture.consume({
|
||||
marker:sharedImageMarker,
|
||||
store:unfiledAttachmentStore,
|
||||
restore:value=>createIssueAttachmentController.restore(value),
|
||||
status:message=>{ qs('#create-issue-attachment-status').textContent = message; },
|
||||
});
|
||||
}
|
||||
clearSharedLaunchUrl();
|
||||
}
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
|
||||
@@ -4012,6 +4126,7 @@
|
||||
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
|
||||
setEventStreamStatus('Refreshing · showing recent snapshot');
|
||||
}
|
||||
renderLiveDataStatus(snapshot.freshness || {});
|
||||
}
|
||||
|
||||
|
||||
@@ -5390,7 +5505,10 @@
|
||||
button.focus();
|
||||
}
|
||||
});
|
||||
qs('#keep-update-unread').addEventListener('click', () => closeUpdateSheet(true));
|
||||
qs('#keep-update-unread').addEventListener('click', () => {
|
||||
if (updateTriage.active()) updateTriage.keepUnreadAndNext();
|
||||
else closeUpdateSheet(true);
|
||||
});
|
||||
qs('#update-ownership-action').addEventListener('click', () => updateOwnership.act());
|
||||
qs('#update-ownership-start').addEventListener('click', () => updateOwnership.start());
|
||||
qs('#retry-update-load').addEventListener('click', () => {
|
||||
@@ -5462,6 +5580,7 @@
|
||||
'Reply and read acknowledgement queued for sync.';
|
||||
if (result?.delivery === 'posted' && result?.next) {
|
||||
notificationUndo.offer(item, result.next.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} catch (error) {
|
||||
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
|
||||
@@ -5476,7 +5595,10 @@
|
||||
qs('#mark-update-read-next').disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.markReadAndNext(lastMyWork);
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
if (result) {
|
||||
notificationUndo.offer(result.item, result.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} finally {
|
||||
qs('#mark-update-read-next').disabled = false;
|
||||
}
|
||||
@@ -5486,7 +5608,31 @@
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
|
||||
if (result) notificationUndo.offer(result.item, result.items);
|
||||
if (result) {
|
||||
notificationUndo.offer(result.item, result.items);
|
||||
if (updateTriage.active()) updateTriage.acceptCompleted();
|
||||
}
|
||||
} finally {
|
||||
button.disabled = offlineWorkMode;
|
||||
}
|
||||
});
|
||||
qs('#mute-update-next').addEventListener('click', async () => {
|
||||
if (!selectedUpdate || offlineWorkMode) return;
|
||||
const button = qs('#mute-update-next');
|
||||
const item = selectedUpdate;
|
||||
button.disabled = true;
|
||||
qs('#update-sheet-status').textContent = 'Muting future updates…';
|
||||
try {
|
||||
await muteNotification(item.notification_id);
|
||||
const result = await notificationReader.acceptReadAndNext(lastMyWork, item);
|
||||
if (result && updateTriage.active()) updateTriage.acceptCompleted();
|
||||
} catch (error) {
|
||||
qs('#update-sheet-status').textContent = error.muted ?
|
||||
'Future updates are muted; current item is still unread. Retry mark read & next.' : error.message;
|
||||
if (error.muted) {
|
||||
button.hidden = true;
|
||||
qs('#mark-update-read-next').focus();
|
||||
}
|
||||
} finally {
|
||||
button.disabled = offlineWorkMode;
|
||||
}
|
||||
@@ -5693,6 +5839,54 @@
|
||||
intervalMs: 8000,
|
||||
});
|
||||
function load() { return contextPoller.refresh({ force: true }); }
|
||||
const liveDataStatusSheet = qs('#live-data-status-sheet');
|
||||
const liveDataStatusTrigger = qs('#open-live-data-status');
|
||||
let latestLiveFreshness = {};
|
||||
function feedAge(feed) {
|
||||
return feed.ageSeconds === null ? 'Snapshot age unavailable' :
|
||||
(feed.ageSeconds < 60 ? feed.ageSeconds + 's old' : Math.floor(feed.ageSeconds / 60) + 'm old');
|
||||
}
|
||||
function renderLiveDataStatus(freshness = latestLiveFreshness) {
|
||||
latestLiveFreshness = freshness;
|
||||
const description = liveDataStatus.describe(freshness);
|
||||
setStatus(description.summary);
|
||||
qs('#live-data-status-feeds').innerHTML = description.feeds.map(feed =>
|
||||
'<div class="live-data-status-feed"><strong>' + escapeHtml(feed.label) + '</strong><span>' +
|
||||
escapeHtml(feed.state === 'live' ? 'Live · ' + feedAge(feed) :
|
||||
feed.state === 'refreshing' ? 'Refreshing · ' + feedAge(feed) : 'Delayed · ' + feedAge(feed)) + '</span></div>'
|
||||
).join('');
|
||||
const pollState = contextPoller.getState();
|
||||
const retrySeconds = description.nextRetrySeconds || (pollState.nextRetryAt ?
|
||||
Math.max(1, Math.ceil((pollState.nextRetryAt - Date.now()) / 1000)) : null);
|
||||
qs('#live-data-status-retry').textContent = retrySeconds ?
|
||||
'Next automatic retry in ' + retrySeconds + 's.' :
|
||||
(pollState.lastSuccessAt ? 'Last successful refresh ' + fmt(new Date(pollState.lastSuccessAt)) + '.' : 'Waiting for the first successful refresh.');
|
||||
}
|
||||
function closeLiveDataStatus() {
|
||||
liveDataStatusSheet.hidden = true;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'false');
|
||||
liveDataStatusTrigger.focus();
|
||||
}
|
||||
liveDataStatusTrigger.addEventListener('click', () => {
|
||||
renderLiveDataStatus();
|
||||
liveDataStatusSheet.hidden = false;
|
||||
liveDataStatusTrigger.setAttribute('aria-expanded', 'true');
|
||||
qs('#close-live-data-status').focus();
|
||||
});
|
||||
qs('#close-live-data-status').addEventListener('click', closeLiveDataStatus);
|
||||
liveDataStatusSheet.addEventListener('click', event => {
|
||||
if (event.target === liveDataStatusSheet) closeLiveDataStatus();
|
||||
});
|
||||
liveDataStatusSheet.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeLiveDataStatus(); }
|
||||
});
|
||||
const liveDataRefresh = liveDataStatus.createRefreshController({
|
||||
button: qs('#refresh-live-data'),
|
||||
output: qs('#live-data-status-result'),
|
||||
refresh: () => contextPoller.refresh({ force: true }),
|
||||
onState: state => { if (state === 'refreshing') setStatus('Refreshing live data'); },
|
||||
});
|
||||
qs('#refresh-live-data').addEventListener('click', liveDataRefresh.run);
|
||||
|
||||
const offlineStatus = qs('#offline-status');
|
||||
const keepWorkOffline = qs('#keep-work-offline');
|
||||
@@ -5775,10 +5969,15 @@
|
||||
if (!hasContextSnapshot) await hydrateOfflineWork();
|
||||
}
|
||||
function reconnectLiveData() {
|
||||
offlineStatus.hidden = true;
|
||||
setOfflineWorkMode(false);
|
||||
setStatus('Reconnecting…');
|
||||
contextPoller.refresh({ force: true }).then(() => {
|
||||
contextPoller.refresh({ force: true }).then(snapshot => {
|
||||
if (!snapshot) {
|
||||
offlineStatus.hidden = false;
|
||||
setOfflineWorkMode(true);
|
||||
return;
|
||||
}
|
||||
offlineStatus.hidden = true;
|
||||
setOfflineWorkMode(false);
|
||||
if (selectedReview && offlineReview) openReviewSheet(selectedReview, reviewTrigger);
|
||||
});
|
||||
}
|
||||
@@ -5886,6 +6085,7 @@
|
||||
return;
|
||||
}
|
||||
if (selectedWorkFilter === 'today') runTodayTransition('start');
|
||||
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
|
||||
else workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||
@@ -6056,9 +6256,7 @@
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
|
||||
} catch (e) {
|
||||
console.warn('Filter save failed', e);
|
||||
}
|
||||
} catch (e) {}
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
@@ -6096,7 +6294,7 @@
|
||||
qs('#work-milestone-filter').addEventListener('change', event => {
|
||||
selectedWorkMilestone = event.target.value;
|
||||
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
|
||||
catch (e) { console.warn('Milestone save failed', e); }
|
||||
catch (e) {}
|
||||
renderMyWork();
|
||||
if (workSession.active()) workSession.reconcile();
|
||||
});
|
||||
@@ -6147,7 +6345,6 @@
|
||||
}).catch(error => {
|
||||
qs('#push-updates').disabled = true;
|
||||
qs('#push-update-status').textContent = 'Update notification settings unavailable.';
|
||||
console.warn('Push notifications unavailable', error);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
@@ -6202,6 +6399,8 @@
|
||||
if (!deviceSetup) await (await ensureDeviceSetup()).open(event);
|
||||
});
|
||||
pushControllerReady.then(ensureDeviceSetup).catch(console.warn);
|
||||
await load();
|
||||
await appShortcut.run();
|
||||
contextPoller.start();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
contextPoller.setVisible(!document.hidden);
|
||||
|
||||
+25
-3
@@ -12,7 +12,7 @@
|
||||
<canvas id="bg"></canvas>
|
||||
<header>
|
||||
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
|
||||
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
|
||||
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
|
||||
<details class="app-menu">
|
||||
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
|
||||
<div class="app-menu-panel">
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
|
||||
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
|
||||
<div class="live-data-status-header">
|
||||
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
|
||||
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
|
||||
</div>
|
||||
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
|
||||
<p id="live-data-status-retry" class="small muted"></p>
|
||||
<div class="live-data-status-actions">
|
||||
<button id="refresh-live-data" type="button">Refresh now</button>
|
||||
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
|
||||
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
|
||||
</div>
|
||||
@@ -735,8 +749,9 @@
|
||||
<div class="small" id="update-sheet-key"></div>
|
||||
<h3 id="update-sheet-title">Unread update</h3>
|
||||
</div>
|
||||
<button id="keep-update-unread" type="button">Keep unread</button>
|
||||
<button id="keep-update-unread" type="button">Keep unread & next</button>
|
||||
</div>
|
||||
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
|
||||
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
|
||||
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
|
||||
<div class="row">
|
||||
@@ -779,6 +794,7 @@
|
||||
</div>
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge & next</button>
|
||||
<button id="mute-update-next" type="button" hidden>Mute future updates & next</button>
|
||||
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
|
||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||
@@ -987,7 +1003,7 @@
|
||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
@@ -1001,6 +1017,7 @@
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/shared-image-capture.js"></script>
|
||||
<script src="static/draft-filing-session.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
@@ -1052,10 +1069,15 @@
|
||||
<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/live-data-status.js"></script>
|
||||
<script src="static/mobile-task-dock.js"></script>
|
||||
<script src="static/mobile-work-entry.js"></script>
|
||||
<script src="static/mobile-queue-launcher.js"></script>
|
||||
<script src="static/update-triage-session.js"></script>
|
||||
<script src="static/update-triage-launcher.js"></script>
|
||||
<script src="static/agenda-session-launcher.js"></script>
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/mobile-device-setup.js"></script>
|
||||
<script src="static/mobile-search-viewport.js"></script>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.liveDataStatus = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||
const feeds = [
|
||||
['context', 'Work'],
|
||||
['notifications', 'Updates'],
|
||||
['events', 'Activity'],
|
||||
];
|
||||
|
||||
function boundedSeconds(value) {
|
||||
const seconds = Number(value);
|
||||
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
|
||||
}
|
||||
|
||||
function describe(freshness = {}) {
|
||||
const sections = freshness.sections || {};
|
||||
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
|
||||
const described = feeds.map(([key, label]) => {
|
||||
const section = sections[key] || {};
|
||||
const state = section.revalidating ? 'refreshing' :
|
||||
(section.stale || section.degraded ? 'delayed' : 'live');
|
||||
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
|
||||
});
|
||||
const delayed = described.filter(feed => feed.state === 'delayed');
|
||||
const refreshing = described.filter(feed => feed.state === 'refreshing');
|
||||
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
|
||||
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
|
||||
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
|
||||
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
|
||||
else if (refreshing.length > 1) summary = 'Refreshing live data';
|
||||
const retryValues = described.map(feed => {
|
||||
const section = sections[feed.key] || {};
|
||||
return boundedSeconds(section.retry_in_seconds);
|
||||
}).filter(value => value !== null && value > 0);
|
||||
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
|
||||
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
|
||||
return {
|
||||
summary,
|
||||
feeds: described,
|
||||
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function createRefreshController({ button, output, refresh, onState = () => {} }) {
|
||||
let pending = null;
|
||||
function run() {
|
||||
if (pending) return pending;
|
||||
button.disabled = true;
|
||||
output.textContent = 'Refreshing live data…';
|
||||
onState('refreshing');
|
||||
let request;
|
||||
try {
|
||||
request = refresh();
|
||||
} catch (error) {
|
||||
request = Promise.reject(error);
|
||||
}
|
||||
pending = Promise.resolve(request).then(result => {
|
||||
if (!result) throw new Error('Live data remains unavailable.');
|
||||
output.textContent = 'Live data refreshed.';
|
||||
onState('success');
|
||||
return result;
|
||||
}).catch(error => {
|
||||
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
|
||||
onState('error');
|
||||
return null;
|
||||
}).finally(() => {
|
||||
button.disabled = false;
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
return { run };
|
||||
}
|
||||
|
||||
return { describe, createRefreshController };
|
||||
});
|
||||
+6
-1
@@ -4,7 +4,7 @@
|
||||
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
|
||||
function validShareContinuation(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
|
||||
const limits = { title: 200, text: 8000, url: 2048 };
|
||||
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
|
||||
const params = new URLSearchParams(value.slice(3));
|
||||
const entries = Array.from(params.entries());
|
||||
if (!entries.length) return './';
|
||||
@@ -14,6 +14,11 @@
|
||||
|| content.length > limits[name]
|
||||
));
|
||||
if (invalid) return './';
|
||||
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
|
||||
const launch = params.get('launch');
|
||||
const shared = params.get('shared');
|
||||
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
|
||||
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,38 @@
|
||||
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Continue work",
|
||||
"short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "New issue",
|
||||
"short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda",
|
||||
"short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "./",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"}
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.mobileAppShortcuts = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function () {
|
||||
const ACTIONS = new Set(['continue', 'new', 'agenda']);
|
||||
|
||||
function parse(search) {
|
||||
const action = new URLSearchParams(search || '').get('launch');
|
||||
return ACTIONS.has(action) ? action : null;
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const launchAction = parse(options.search);
|
||||
let handled = false;
|
||||
|
||||
function action() { return launchAction; }
|
||||
|
||||
function run() {
|
||||
if (!launchAction || handled) return null;
|
||||
handled = true;
|
||||
if (launchAction === 'continue') return options.continueWork();
|
||||
if (launchAction === 'new') return options.newIssue();
|
||||
return options.agenda();
|
||||
}
|
||||
|
||||
return {action, run};
|
||||
}
|
||||
|
||||
return {parse, createController};
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
const continuation = [
|
||||
['attention', 'Start Attention'],
|
||||
['today', 'Continue Today'],
|
||||
['update', 'Resume Updates'],
|
||||
['agenda', 'Open Agenda'],
|
||||
['later', 'Start Later'],
|
||||
['draft', 'Open Drafts'],
|
||||
@@ -27,6 +28,8 @@
|
||||
|
||||
function open(name) {
|
||||
if (name === 'today') return options.openToday();
|
||||
if (name === 'agenda') return options.openAgenda();
|
||||
if (name === 'update' && options.openUpdates) return options.openUpdates();
|
||||
options.selectFilter(name);
|
||||
const action = options.firstAction(name);
|
||||
if (!action) {
|
||||
|
||||
@@ -64,19 +64,15 @@
|
||||
}
|
||||
|
||||
function updateWork(mode) {
|
||||
const labels = {
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
find: ['Find', 'Find work'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
|
||||
if (buttons.work) {
|
||||
buttons.work.setAttribute('aria-label', labels[workMode][1]);
|
||||
}
|
||||
const text = {
|
||||
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
|
||||
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
|
||||
}[mode] || 'Work';
|
||||
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
|
||||
if (options.workLabel) options.workLabel.textContent = text;
|
||||
if (buttons.work) buttons.work.setAttribute('aria-label', queue
|
||||
? (mode === 'update' ? 'Resume ' : 'Open ') + text
|
||||
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
|
||||
}
|
||||
|
||||
function updateAttention(count) {
|
||||
@@ -97,7 +93,8 @@
|
||||
function updateQueues(counts) {
|
||||
const names = 'today agenda attention update later draft'.split(' ');
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
|
||||
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
|
||||
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
@@ -105,8 +102,8 @@
|
||||
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
|
||||
);
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
options.queueBadge.textContent = active + ' active';
|
||||
options.queueBadge.hidden = active === 0;
|
||||
}
|
||||
if (options.deadlineBadge) {
|
||||
options.deadlineBadge.textContent = normalized.agenda + ' due';
|
||||
@@ -116,10 +113,16 @@
|
||||
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
|
||||
else options.queueRows.agenda.removeAttribute('data-deadlines');
|
||||
}
|
||||
const deadlineLabel = normalized.agenda > 0
|
||||
? ', ' + normalized.agenda + ' upcoming deadlines'
|
||||
: '';
|
||||
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items' + deadlineLabel);
|
||||
const label = active === 0 && normalized.agenda === 0
|
||||
? 'Queues: no active queues; no upcoming deadlines'
|
||||
: 'Queues: Today ' + normalized.today
|
||||
+ ', Agenda ' + normalized.agenda + ' due'
|
||||
+ ', Attention ' + normalized.attention
|
||||
+ ', Updates ' + normalized.update
|
||||
+ ', Later ' + normalized.later
|
||||
+ ', Drafts ' + normalized.draft
|
||||
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
|
||||
buttons.queues?.setAttribute('aria-label', label);
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
|
||||
function mode() {
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
const recommended = options.queueLauncher && options.queueLauncher.recommend();
|
||||
if (recommended && recommended.name !== 'today') return recommended.name;
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
@@ -16,7 +18,8 @@
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.findWork();
|
||||
else if (current === 'find') options.findWork();
|
||||
else options.queueLauncher.open(current);
|
||||
return current;
|
||||
}
|
||||
|
||||
|
||||
+24
-10
@@ -248,7 +248,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
return {
|
||||
let completing = null;
|
||||
const pager = {
|
||||
reset(next) {
|
||||
pagination = { ...pagination, ...(next || {}) };
|
||||
onPagination(pagination);
|
||||
@@ -282,7 +283,19 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
|
||||
pending = false;
|
||||
}
|
||||
},
|
||||
loadAll(getExisting) {
|
||||
if (completing) return completing;
|
||||
completing = (async () => {
|
||||
while (pagination.has_more) {
|
||||
const loaded = await pager.loadMore(getExisting());
|
||||
if (!loaded) return false;
|
||||
}
|
||||
return true;
|
||||
})().finally(() => { completing = null; });
|
||||
return completing;
|
||||
},
|
||||
};
|
||||
return pager;
|
||||
}
|
||||
|
||||
function createWorkPager({ load, onItems, onPagination, onStatus }) {
|
||||
@@ -675,13 +688,14 @@ function createWorkSessionCheckpoint({
|
||||
|
||||
function createWorkSession({
|
||||
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
|
||||
checkpointEnabled = () => true,
|
||||
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
|
||||
onOpen, onProgress, onFinish,
|
||||
}) {
|
||||
let currentIdentity = '';
|
||||
let currentIndex = -1;
|
||||
let running = false;
|
||||
let durable = false;
|
||||
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
|
||||
|
||||
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
|
||||
const report = (items, index) => onProgress({
|
||||
@@ -696,7 +710,7 @@ function createWorkSession({
|
||||
durable = false;
|
||||
currentIdentity = '';
|
||||
currentIndex = -1;
|
||||
if (clearCheckpoint) checkpoint?.clear();
|
||||
if (clearCheckpoint) activeCheckpoint()?.clear();
|
||||
onFinish();
|
||||
return false;
|
||||
};
|
||||
@@ -704,7 +718,7 @@ function createWorkSession({
|
||||
if (!items.length || index < 0 || index >= items.length) return finish();
|
||||
currentIndex = index;
|
||||
currentIdentity = workIdentity(items[index]);
|
||||
if (durable) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
@@ -712,9 +726,9 @@ function createWorkSession({
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
|
||||
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
|
||||
end: () => finish(),
|
||||
resumable: () => Boolean(checkpoint?.read()),
|
||||
resumable: () => Boolean(activeCheckpoint()?.read()),
|
||||
reopen(requested = null) {
|
||||
if (!running) return false;
|
||||
const items = queue();
|
||||
@@ -723,13 +737,13 @@ function createWorkSession({
|
||||
if (index < 0) return false;
|
||||
currentIdentity = requestedIdentity;
|
||||
currentIndex = index;
|
||||
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
|
||||
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
|
||||
report(items, index);
|
||||
onOpen(items[index]);
|
||||
return true;
|
||||
},
|
||||
resume(requested = null) {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return false;
|
||||
durable = true;
|
||||
const items = queue();
|
||||
@@ -743,7 +757,7 @@ function createWorkSession({
|
||||
const items = queue();
|
||||
if (!items.length) return finish();
|
||||
running = true;
|
||||
durable = Boolean(checkpoint && checkpointEnabled());
|
||||
durable = Boolean(activeCheckpoint() && checkpointEnabled());
|
||||
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
|
||||
return openAt(items, requested >= 0 ? requested : 0);
|
||||
},
|
||||
@@ -791,7 +805,7 @@ function createWorkSession({
|
||||
if (!items.length) return null;
|
||||
if (action === 'start') return items[0];
|
||||
if (action === 'resume') {
|
||||
const saved = checkpoint?.read();
|
||||
const saved = activeCheckpoint()?.read();
|
||||
if (!saved) return null;
|
||||
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
|
||||
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v99';
|
||||
const CACHE = 'stackchain-dashboard-shell-v100';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -21,6 +21,7 @@ const SHELL = [
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/shared-image-capture.js',
|
||||
BASE + 'static/draft-filing-session.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
@@ -71,10 +72,15 @@ const SHELL = [
|
||||
BASE + 'static/work-route.js',
|
||||
BASE + 'static/task-overlay-history.js',
|
||||
BASE + 'static/context-poller.js',
|
||||
BASE + 'static/live-data-status.js',
|
||||
BASE + 'static/mobile-task-dock.js',
|
||||
BASE + 'static/mobile-work-entry.js',
|
||||
BASE + 'static/mobile-queue-launcher.js',
|
||||
BASE + 'static/update-triage-session.js',
|
||||
BASE + 'static/update-triage-launcher.js',
|
||||
BASE + 'static/agenda-session-launcher.js',
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/mobile-device-setup.js',
|
||||
BASE + 'static/mobile-search-viewport.js',
|
||||
@@ -86,6 +92,44 @@ const SHELL = [
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
const SHARED_IMAGE_ID = 'shared-image';
|
||||
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
|
||||
|
||||
function boundedShareField(form, name, limit) {
|
||||
const value = form.get(name);
|
||||
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
|
||||
}
|
||||
|
||||
async function acceptSharedContent(request) {
|
||||
const form = await request.formData();
|
||||
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
|
||||
let marker = '';
|
||||
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
|
||||
if (images.length > 1) marker = 'multiple';
|
||||
else if (images.length === 1) {
|
||||
const image = images[0];
|
||||
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
|
||||
marker = 'unsupported';
|
||||
} else {
|
||||
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
|
||||
filename:String(image.name || 'shared-screenshot').slice(0, 255),
|
||||
contentType:String(image.type), blob:image,
|
||||
});
|
||||
marker = 'image';
|
||||
}
|
||||
}
|
||||
const target = new URL(BASE, self.location.origin);
|
||||
target.searchParams.set('launch', 'new');
|
||||
if (marker) target.searchParams.set('shared', marker);
|
||||
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
|
||||
const value = boundedShareField(form, name, limit);
|
||||
if (value) target.searchParams.set(name, value);
|
||||
}
|
||||
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
|
||||
}
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
let timeout;
|
||||
@@ -462,6 +506,14 @@ self.addEventListener('notificationclick', event => {
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
request.method === 'POST' && request.mode === 'navigate'
|
||||
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
|
||||
) {
|
||||
event.respondWith(acceptSharedContent(request));
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'GET' || request.url.includes('/api/')) return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
(function(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === 'object' && module.exports) module.exports = api;
|
||||
else root.sharedImageCapture = api;
|
||||
})(typeof self !== 'undefined' ? self : this, function() {
|
||||
'use strict';
|
||||
|
||||
const RECORD_ID = 'shared-image';
|
||||
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
async function consume({marker, store, restore, status = () => {}}) {
|
||||
if (!marker) return false;
|
||||
if (marker !== 'image') {
|
||||
status('Share one PNG, JPEG, or WebP screenshot.');
|
||||
return false;
|
||||
}
|
||||
const value = await store?.get(RECORD_ID);
|
||||
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
|
||||
status('The shared screenshot is unavailable. Share it again.');
|
||||
return false;
|
||||
}
|
||||
restore(value);
|
||||
await store.delete(RECORD_ID);
|
||||
status('Shared screenshot ready to file with this issue.');
|
||||
return true;
|
||||
}
|
||||
|
||||
return {consume, RECORD_ID};
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageLauncher = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
|
||||
let pending = null;
|
||||
|
||||
function open() {
|
||||
if (pending) return pending;
|
||||
options.selectUpdates();
|
||||
options.announce('Checking all unread updates…');
|
||||
pending = Promise.resolve()
|
||||
.then(() => options.discover())
|
||||
.then(discovery => {
|
||||
if (!options.isUpdatesSelected()) return 'cancelled';
|
||||
if (discovery === false || discovery?.complete === false) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
if (discovery?.complete) options.applySnapshot?.(discovery);
|
||||
if (options.hasMore()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
return 'incomplete';
|
||||
}
|
||||
const opened = options.hasCheckpoint() ? options.resume() : options.start();
|
||||
if (!opened) options.announce('No unread updates are ready.');
|
||||
return opened ? 'opened' : 'empty';
|
||||
})
|
||||
.catch(() => {
|
||||
if (options.isUpdatesSelected()) {
|
||||
options.announce('Updates check paused. Retry to check older unread updates.');
|
||||
}
|
||||
return 'incomplete';
|
||||
})
|
||||
.finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
|
||||
return {open};
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createUpdateTriageSession = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
|
||||
const key = options.key || 'stackchain.update-triage.v1';
|
||||
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
|
||||
const login = () => String(options.getLogin() || '').trim();
|
||||
let state = null;
|
||||
let running = false;
|
||||
|
||||
function read() {
|
||||
const owner = login();
|
||||
if (!owner) return null;
|
||||
try {
|
||||
const value = JSON.parse(options.storage.getItem(key) || 'null');
|
||||
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
|
||||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
|
||||
const identities = value.identities.filter(value => typeof value === 'string' && value);
|
||||
const completed = value.completed.filter(value => identities.includes(value));
|
||||
if (!identities.length || !identities.includes(value.current)) return null;
|
||||
return { version:1, login:owner, identities, current:value.current, completed };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist() {
|
||||
if (!state) return;
|
||||
options.storage.setItem(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function available() {
|
||||
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
|
||||
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
|
||||
.map(id => byIdentity.get(id));
|
||||
}
|
||||
|
||||
function finish() {
|
||||
running = false;
|
||||
state = null;
|
||||
options.storage.removeItem(key);
|
||||
options.onFinish();
|
||||
return false;
|
||||
}
|
||||
|
||||
function openCurrent(preferredIndex = null) {
|
||||
const items = available();
|
||||
if (!items.length) return finish();
|
||||
let index = items.findIndex(item => identity(item) === state.current);
|
||||
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
|
||||
state.current = identity(items[index]);
|
||||
persist();
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
|
||||
options.onOpen(items[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function advance(openNext = true) {
|
||||
if (!running || !state) return false;
|
||||
const originalIndex = state.identities.indexOf(state.current);
|
||||
if (!state.completed.includes(state.current)) state.completed.push(state.current);
|
||||
const candidates = available();
|
||||
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
|
||||
if (!next) return finish();
|
||||
state.current = identity(next);
|
||||
if (openNext) return openCurrent();
|
||||
persist();
|
||||
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
active: () => running,
|
||||
resumable: () => Boolean(read()),
|
||||
start() {
|
||||
const identities = (options.getItems() || []).map(identity).filter(Boolean);
|
||||
if (!identities.length) return finish();
|
||||
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
|
||||
if (!state.login) return false;
|
||||
running = true;
|
||||
return openCurrent();
|
||||
},
|
||||
resume() {
|
||||
state = read();
|
||||
if (!state) return false;
|
||||
running = true;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
reconcile() {
|
||||
if (!running || !state) return false;
|
||||
return openCurrent(state.identities.indexOf(state.current));
|
||||
},
|
||||
completeAndNext: advance,
|
||||
acceptCompleted: () => advance(false),
|
||||
keepUnreadAndNext: advance,
|
||||
items: () => state ? available().slice() : [],
|
||||
end: finish,
|
||||
};
|
||||
});
|
||||
@@ -28,7 +28,7 @@ FEATURE_SOURCES = {
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
|
||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||
|
||||
+34
-3
@@ -4,7 +4,7 @@ import re
|
||||
import shlex
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
from urllib.parse import quote, urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -916,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
|
||||
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
|
||||
|
||||
|
||||
async def _notification_subscription(repository: str, number: str) -> dict:
|
||||
try:
|
||||
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
|
||||
except Exception:
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
async def notification_detail(thread_id: int) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
@@ -940,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
)
|
||||
if supported_conversation:
|
||||
assert conversation_match is not None
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||
fetch(subject_path),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
issue_conversation_page(
|
||||
conversation_match.group(1), int(conversation_match.group(3))
|
||||
),
|
||||
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
|
||||
)
|
||||
else:
|
||||
subject_detail, comment, conversation = await asyncio.gather(
|
||||
subject_detail, comment, conversation, subscription = await asyncio.gather(
|
||||
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
|
||||
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
|
||||
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
|
||||
asyncio.sleep(0, result={}),
|
||||
)
|
||||
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
|
||||
comment = comment if isinstance(comment, dict) else {}
|
||||
@@ -1012,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict:
|
||||
and comment_match
|
||||
and comment_match.group(1) == repository_name
|
||||
),
|
||||
"mute_supported": bool(
|
||||
supported_conversation
|
||||
and isinstance(subscription, dict)
|
||||
and subscription.get("subscribed") is True
|
||||
and subscription.get("ignored") is not True
|
||||
),
|
||||
"conversation": conversation,
|
||||
}
|
||||
|
||||
@@ -1058,6 +1074,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
|
||||
return match.group(1), int(match.group(3))
|
||||
|
||||
|
||||
async def mute_notification(thread_id: int) -> dict:
|
||||
"""Unsubscribe the current operator from the trusted notification conversation."""
|
||||
repository, number = await notification_conversation_target(thread_id)
|
||||
user = await fetch("user")
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("Gitea did not identify the current operator")
|
||||
response = await _get_client().delete(
|
||||
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
|
||||
headers=_auth(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
|
||||
|
||||
|
||||
async def reply_to_notification(thread_id: int, body: str) -> dict:
|
||||
thread = await fetch(f"notifications/threads/{thread_id}")
|
||||
if not isinstance(thread, dict):
|
||||
|
||||
+63
@@ -1012,6 +1012,17 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||
for name in limits
|
||||
if request.query_params.get(name)
|
||||
]
|
||||
launch = request.query_params.get("launch", "")
|
||||
if launch in {"continue", "new", "agenda"}:
|
||||
shared.append(("launch", launch))
|
||||
image_markers = request.query_params.getlist("shared")
|
||||
if image_markers:
|
||||
if (
|
||||
image_markers != ["image"]
|
||||
or request.query_params.getlist("launch") != ["new"]
|
||||
):
|
||||
return "login"
|
||||
shared.append(("shared", "image"))
|
||||
if dashboard_auth.application_path(request) != "/" or not shared:
|
||||
return "login"
|
||||
continuation = f"./?{urlencode(shared)}"
|
||||
@@ -3581,6 +3592,21 @@ async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/snapshot")
|
||||
async def notification_snapshot() -> JSONResponse:
|
||||
try:
|
||||
result = await gitea_proxy.unread_notification_snapshot(
|
||||
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Complete unread updates are temporarily unavailable. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/api/v1/notifications/{thread_id}")
|
||||
async def notification_thread_detail(
|
||||
thread_id: int = PathParam(gt=0),
|
||||
@@ -3674,6 +3700,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
return JSONResponse({"id": thread_id, "status": "unread"})
|
||||
|
||||
|
||||
@app.post("/api/v1/notifications/{thread_id}/mute")
|
||||
async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
gitea_proxy.mute_notification(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
{"error": "Muting future updates timed out. Please retry."},
|
||||
status_code=503,
|
||||
headers={"Retry-After": "1"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": "Future updates could not be muted. Please retry."},
|
||||
status_code=503,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
mark_notification_read(thread_id),
|
||||
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": thread_id,
|
||||
"muted": True,
|
||||
"status": "unread",
|
||||
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
await _remove_notifications_from_live_snapshot([thread_id])
|
||||
return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
|
||||
|
||||
|
||||
@app.patch("/api/v1/notifications/{thread_id}/later")
|
||||
async def defer_notification(
|
||||
payload: NotificationLaterRequest,
|
||||
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
@@ -876,6 +876,55 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
|
||||
invalid = await client.get("/", params={"launch": "delete-account"})
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": ["./?launch=agenda"]
|
||||
}
|
||||
assert invalid.status_code == 303
|
||||
assert invalid.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
valid = await client.get(
|
||||
"/",
|
||||
params={
|
||||
"launch": "new",
|
||||
"shared": "image",
|
||||
"title": "Broken checkout",
|
||||
"text": "Steps to reproduce",
|
||||
},
|
||||
)
|
||||
wrong_launch = await client.get(
|
||||
"/", params={"launch": "agenda", "shared": "image"}
|
||||
)
|
||||
wrong_marker = await client.get(
|
||||
"/", params={"launch": "new", "shared": "document"}
|
||||
)
|
||||
duplicate_marker = await client.get(
|
||||
"/?launch=new&shared=image&shared=image"
|
||||
)
|
||||
|
||||
assert valid.status_code == 303
|
||||
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
|
||||
"continue": [
|
||||
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
|
||||
]
|
||||
}
|
||||
assert wrong_launch.headers["location"] == "login"
|
||||
assert wrong_marker.headers["location"] == "login"
|
||||
assert duplicate_marker.headers["location"] == "login"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_share_target_is_not_carried_through_login(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v99';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v100';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"number": 7, "state": "open", "assignees": [],
|
||||
"body": "Deploy fails after **three** retries.",
|
||||
})
|
||||
if request.url.path.endswith("/issues/7/subscriptions/check"):
|
||||
return httpx.Response(200, json={"subscribed": True, "ignored": False})
|
||||
if request.url.path.endswith("/issues/comments/9"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 9,
|
||||
@@ -283,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
|
||||
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
|
||||
]
|
||||
assert result == {
|
||||
"id": 42,
|
||||
@@ -300,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
|
||||
},
|
||||
"issue": {"number": 7, "assignees": [], "claimable": True},
|
||||
"acknowledge_supported": True,
|
||||
"mute_supported": True,
|
||||
"conversation": {
|
||||
"comments": [{
|
||||
"id": 9,
|
||||
@@ -398,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
|
||||
"created_at": "2026-08-07T19:00:00Z",
|
||||
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
|
||||
requests = []
|
||||
|
||||
def upstream(request):
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.url.path.endswith("/notifications/threads/42"):
|
||||
return httpx.Response(200, json={
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"subject": {
|
||||
"type": "Pull",
|
||||
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
|
||||
},
|
||||
})
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"login": "timmy"})
|
||||
if request.method == "DELETE":
|
||||
return httpx.Response(204)
|
||||
return httpx.Response(404)
|
||||
|
||||
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
|
||||
try:
|
||||
result = await gitea_proxy.mute_notification(42)
|
||||
finally:
|
||||
await gitea_proxy.stop_client()
|
||||
|
||||
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
|
||||
assert requests == [
|
||||
("GET", "/api/v1/notifications/threads/42"),
|
||||
("GET", "/api/v1/user"),
|
||||
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
|
||||
]
|
||||
@@ -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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/later-sync.js'" in source
|
||||
@@ -0,0 +1,101 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
STATUS = ROOT / "frontend" / "live-data-status.js"
|
||||
HTML = ROOT / "frontend" / "index.html"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_live_data_status_summarizes_each_feed_without_claiming_live():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
const healthy = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
|
||||
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
const twoDelayed = {{fresh_for_seconds:8, sections:{{
|
||||
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
|
||||
}}}};
|
||||
process.stdout.write(JSON.stringify({{
|
||||
healthy:status.describe(healthy),
|
||||
one:status.describe(oneDelayed),
|
||||
two:status.describe(twoDelayed),
|
||||
unavailable:status.describe({{}}),
|
||||
}}));
|
||||
"""
|
||||
result = run_node(script)
|
||||
|
||||
assert result["healthy"]["summary"] == "Live"
|
||||
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
|
||||
assert result["one"]["summary"] == "Updates delayed"
|
||||
assert result["one"]["nextRetrySeconds"] == 30
|
||||
assert result["one"]["feeds"][1] == {
|
||||
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
|
||||
}
|
||||
assert result["two"]["summary"] == "2 data feeds delayed"
|
||||
assert result["unavailable"]["summary"] == "Live data unavailable"
|
||||
|
||||
|
||||
def test_live_data_status_controller_is_single_flight_and_reports_result():
|
||||
script = f"""
|
||||
const status = require({json.dumps(str(STATUS))});
|
||||
let resolveRefresh;
|
||||
let calls = 0;
|
||||
const states = [];
|
||||
const button = {{disabled:false}};
|
||||
const output = {{textContent:''}};
|
||||
const controller = status.createRefreshController({{
|
||||
button, output,
|
||||
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
|
||||
onState:value => states.push(value),
|
||||
}});
|
||||
(async () => {{
|
||||
const first = controller.run();
|
||||
const second = controller.run();
|
||||
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
|
||||
resolveRefresh({{context:{{}}}});
|
||||
await Promise.all([first, second]);
|
||||
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
|
||||
}})();
|
||||
"""
|
||||
|
||||
assert run_node(script) == {
|
||||
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
|
||||
"calls": 1,
|
||||
"disabled": False,
|
||||
"text": "Live data refreshed.",
|
||||
"states": ["refreshing", "success"],
|
||||
}
|
||||
|
||||
|
||||
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
|
||||
html = HTML.read_text()
|
||||
css = CSS.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="open-live-data-status"' in html
|
||||
assert 'aria-controls="live-data-status-sheet"' in html
|
||||
assert 'id="live-data-status-sheet"' in html
|
||||
assert 'aria-labelledby="live-data-status-heading"' in html
|
||||
assert 'id="live-data-status-feeds"' in html
|
||||
assert 'id="refresh-live-data"' in html
|
||||
assert '<script src="static/live-data-status.js"></script>' in html
|
||||
assert ".live-data-status-panel" in css
|
||||
assert "width:min(560px,100%)" in css
|
||||
assert "min-height:44px" in css
|
||||
assert "liveDataStatus.describe" in dashboard
|
||||
assert "liveDataStatus.createRefreshController" in dashboard
|
||||
assert "contextPoller.getState()" in dashboard
|
||||
@@ -370,6 +370,44 @@ setTimeout(() => process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_login_continues_only_bounded_shared_screenshot_capture():
|
||||
harness = f"""
|
||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||
async function destination(continuation) {{
|
||||
let replaced = null;
|
||||
const controller = createLoginController({{
|
||||
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
|
||||
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
|
||||
location: {{ replace: value => replaced = value }}, continuation,
|
||||
}});
|
||||
await controller.submit('operator-token');
|
||||
return replaced;
|
||||
}}
|
||||
(async () => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
valid: await destination('./?title=Broken&launch=new&shared=image'),
|
||||
wrongLaunch: await destination('./?launch=agenda&shared=image'),
|
||||
missingLaunch: await destination('./?shared=image'),
|
||||
wrongMarker: await destination('./?launch=new&shared=document'),
|
||||
duplicateMarker: await destination('./?launch=new&shared=image&shared=image'),
|
||||
launchWithoutMarker: await destination('./?launch=new'),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", harness], text=True, capture_output=True, check=True
|
||||
)
|
||||
|
||||
assert json.loads(result.stdout) == {
|
||||
"valid": "./?title=Broken&launch=new&shared=image",
|
||||
"wrongLaunch": "./",
|
||||
"missingLaunch": "./",
|
||||
"wrongMarker": "./",
|
||||
"duplicateMarker": "./",
|
||||
"launchWithoutMarker": "./?launch=new",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_page_loads_rate_limit_controller():
|
||||
html = await login()
|
||||
|
||||
@@ -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-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
@@ -45,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||
|
||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -214,7 +214,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v99" in worker
|
||||
assert "stackchain-dashboard-shell-v100" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
@@ -8,6 +8,33 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
|
||||
APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
|
||||
|
||||
|
||||
def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
|
||||
script = f"""
|
||||
const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
|
||||
const calls = [];
|
||||
const controller = shortcuts.createController({{
|
||||
search:'?launch=continue',
|
||||
continueWork:() => calls.push('continue'),
|
||||
newIssue:() => calls.push('new'),
|
||||
agenda:() => calls.push('agenda'),
|
||||
}});
|
||||
Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
|
||||
process.stdout.write(JSON.stringify({{
|
||||
action:controller.action(), calls,
|
||||
invalid:shortcuts.parse('?launch=delete-account'),
|
||||
shared:shortcuts.parse('?launch=new&title=Shared'),
|
||||
}}));
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
|
||||
@@ -63,8 +90,13 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
|
||||
assert 'id="work-settings-toggle"' in html
|
||||
assert 'id="active-work-queue"' in html
|
||||
assert '<script src="static/mobile-launch.js"></script>' in html
|
||||
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
|
||||
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "BASE + 'static/mobile-launch.js'" in service_worker
|
||||
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
|
||||
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "mobileAppShortcuts.createController" in bootstrap
|
||||
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
|
||||
assert "mobileLaunch.chooseFilter" in html
|
||||
assert "agenda: counts.agenda" in html
|
||||
assert "mobileLaunch.createDisclosure" in html
|
||||
|
||||
@@ -48,6 +48,43 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const calls = [];
|
||||
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
getEligibleCount: () => state.eligible,
|
||||
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
planToday: () => calls.push('plan'),
|
||||
|
||||
findWork: () => calls.push('find'),
|
||||
}});
|
||||
const modes = [entry.open()];
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.next = {{name:'today'}}; modes.push(entry.open());
|
||||
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
|
||||
state.next = {{name:'agenda'}}; modes.push(entry.open());
|
||||
state.next = {{name:'later'}}; modes.push(entry.open());
|
||||
state.next = {{name:'draft'}}; modes.push(entry.open());
|
||||
state.next = {{name:'find'}}; 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", "attention", "resume", "update", "agenda", "later", "draft", "find"],
|
||||
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -314,14 +351,22 @@ queues.click(); rows.recaps.click();
|
||||
const closedAfterUtility = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
const populated = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
|
||||
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:7, later:0, draft:0}});
|
||||
const updatesOnly = {{
|
||||
badge:badge.textContent, badgeHidden:badge.hidden,
|
||||
deadlineHidden:deadline.hidden,
|
||||
badgeLabel:queues.attributes['aria-label'],
|
||||
}};
|
||||
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
|
||||
badge:badge.textContent, populated,
|
||||
badge:badge.textContent, populated, updatesOnly,
|
||||
clearedBadgeHidden:badge.hidden,
|
||||
clearedDeadlineHidden:deadline.hidden,
|
||||
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
|
||||
clearedBadgeLabel:queues.attributes['aria-label'],
|
||||
@@ -340,12 +385,25 @@ process.stdout.write(JSON.stringify({{
|
||||
"closedAfterUtility": True,
|
||||
"selected": ["update"],
|
||||
"utilities": ["recaps:trigger"],
|
||||
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
|
||||
"badge": "0",
|
||||
"populated": {"badgeLabel": "Queues, 10 items, 5 upcoming deadlines", "deadline": "5 due", "deadlineHidden": False, "agendaDue": "true"},
|
||||
"badge": "0 active",
|
||||
"populated": {
|
||||
"badge": "5 active",
|
||||
"badgeHidden": False,
|
||||
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
|
||||
"deadline": "5 due",
|
||||
"deadlineHidden": False,
|
||||
"agendaDue": "true",
|
||||
},
|
||||
"updatesOnly": {
|
||||
"badge": "1 active",
|
||||
"badgeHidden": False,
|
||||
"deadlineHidden": True,
|
||||
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
|
||||
},
|
||||
"clearedBadgeHidden": True,
|
||||
"clearedDeadlineHidden": True,
|
||||
"clearedAgendaDue": None,
|
||||
"clearedBadgeLabel": "Queues, 0 items",
|
||||
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
|
||||
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
|
||||
"updateLabel": "Updates, 0 unread conversations",
|
||||
"queueFocuses": 3,
|
||||
@@ -388,6 +446,7 @@ const calls = [];
|
||||
let counts = {{attention: 2, today: 1, agenda: 5, later: 4, draft: 3}};
|
||||
const launcher = createLauncher({{
|
||||
openToday: () => calls.push('today'),
|
||||
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
|
||||
announce: message => calls.push('announce:' + message),
|
||||
@@ -411,7 +470,60 @@ process.stdout.write(JSON.stringify({{first, agenda, opened, fallback, calls}}))
|
||||
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
|
||||
"opened": "opened",
|
||||
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
|
||||
"calls": ["filter:agenda", "open:agenda", "find"],
|
||||
"calls": ["agenda", "find"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
|
||||
const calls = [];
|
||||
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
|
||||
const launcher = createLauncher({{
|
||||
getCounts: () => counts,
|
||||
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
|
||||
openAgenda: () => calls.push('agenda'),
|
||||
selectFilter: name => calls.push('filter:' + name),
|
||||
firstAction: () => null,
|
||||
announce: () => {{}},
|
||||
openFindWork: () => calls.push('find'),
|
||||
}});
|
||||
const recommendation = launcher.recommend();
|
||||
const opened = launcher.continueWork();
|
||||
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
|
||||
"opened": "resumed",
|
||||
"calls": ["updates"],
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}};
|
||||
const workLabel = {{textContent:''}};
|
||||
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
|
||||
const labels = {{}};
|
||||
for (const mode of ['attention','update','agenda','later','draft']) {{
|
||||
dock.updateWork(mode);
|
||||
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
|
||||
}}
|
||||
process.stdout.write(JSON.stringify(labels));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"attention": ["Attention", "Open Attention"],
|
||||
"update": ["Updates", "Resume Updates"],
|
||||
"agenda": ["Agenda", "Open Agenda"],
|
||||
"later": ["Later", "Open Later"],
|
||||
"draft": ["Drafts", "Open Drafts"],
|
||||
}
|
||||
|
||||
|
||||
@@ -485,6 +597,17 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
assert "runTodayTransition('continue')" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
|
||||
html = await dashboard()
|
||||
|
||||
assert "queueLauncher: mobileQueueLauncher" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "continueWork: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileQueueCounts = counts;" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
+311
-4
@@ -40,6 +40,219 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
|
||||
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
|
||||
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
|
||||
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
|
||||
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
|
||||
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
|
||||
|
||||
|
||||
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
let release;
|
||||
let discoveries = 0;
|
||||
let opens = 0;
|
||||
const messages = [];
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => {{}},
|
||||
discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => false,
|
||||
resume: () => {{ throw new Error('must start'); }},
|
||||
start: () => {{ opens += 1; return true; }},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
const first = launcher.open();
|
||||
const second = launcher.open();
|
||||
Promise.resolve().then(() => {{
|
||||
const before = {{discoveries, opens, same:first === second}};
|
||||
release(true);
|
||||
return Promise.all([first, second]).then(results =>
|
||||
process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
|
||||
);
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"discoveries": 1, "opens": 0, "same": True},
|
||||
"discoveries": 1,
|
||||
"opens": 1,
|
||||
"results": ["opened", "opened"],
|
||||
"messages": ["Checking all unread updates…"],
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
const messages = [];
|
||||
let resumed = 0;
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => {{}},
|
||||
discover: async () => false,
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => true,
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ resumed += 1; return true; }},
|
||||
start: () => true,
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "incomplete",
|
||||
"resumed": 0,
|
||||
"messages": [
|
||||
"Checking all unread updates…",
|
||||
"Updates check paused. Retry to check older unread updates.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
|
||||
const events = [];
|
||||
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
|
||||
const launcher = createLauncher({{
|
||||
selectUpdates: () => events.push('selected'),
|
||||
discover: async () => snapshot,
|
||||
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
|
||||
isUpdatesSelected: () => true,
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => false,
|
||||
resume: () => {{ throw new Error('must start'); }},
|
||||
start: () => {{ events.push('started'); return true; }},
|
||||
announce: () => {{}},
|
||||
}});
|
||||
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"result": "opened",
|
||||
"events": ["selected", ["applied", [1, 51]], "started"],
|
||||
}
|
||||
|
||||
|
||||
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
let items = [{{id:1}}];
|
||||
let fail = true;
|
||||
const requested = [];
|
||||
const pager = buildMyWork.createNotificationPager({{
|
||||
load: async page => {{
|
||||
requested.push(page);
|
||||
if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
|
||||
return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
|
||||
{{page:3,total:101,has_more:false,items:[{{id:101}}]}};
|
||||
}},
|
||||
onNotifications: value => {{ items = value; }},
|
||||
onPagination: () => {{}},
|
||||
onStatus: () => {{}},
|
||||
}});
|
||||
pager.reset({{page:1,total:101,has_more:true}});
|
||||
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
|
||||
process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
|
||||
));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"firstResult": False,
|
||||
"secondResult": True,
|
||||
"requested": [2, 2, 3],
|
||||
"ids": [1, 51, 101],
|
||||
}
|
||||
|
||||
|
||||
def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
||||
let finishDiscovery;
|
||||
let discoveries = 0;
|
||||
let opens = 0;
|
||||
let queue = 'agenda';
|
||||
const launcher = createLauncher({{
|
||||
selectAgenda: () => {{ queue = 'agenda'; }},
|
||||
discover: () => {{
|
||||
discoveries += 1;
|
||||
return new Promise(resolve => {{ finishDiscovery = resolve; }});
|
||||
}},
|
||||
isAgendaSelected: () => queue === 'agenda',
|
||||
hasMore: () => false,
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ opens += 1; return true; }},
|
||||
start: () => {{ throw new Error('must resume'); }},
|
||||
announce: () => {{}},
|
||||
}});
|
||||
const first = launcher.open();
|
||||
const second = launcher.open();
|
||||
Promise.resolve().then(() => {{
|
||||
const before = {{discoveries, opens, same:first === second}};
|
||||
finishDiscovery(true);
|
||||
return Promise.all([first, second]).then(results => {{
|
||||
process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"before": {"discoveries": 1, "opens": 0, "same": True},
|
||||
"results": ["opened", "opened"],
|
||||
"discoveries": 1,
|
||||
"opens": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
|
||||
script = f"""
|
||||
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
|
||||
async function scenario(mode) {{
|
||||
let queue = 'agenda';
|
||||
let opens = 0;
|
||||
const messages = [];
|
||||
const launcher = createLauncher({{
|
||||
selectAgenda: () => {{ queue = 'agenda'; }},
|
||||
discover: async () => {{
|
||||
if (mode === 'leave') queue = 'today';
|
||||
if (mode === 'throw') throw new Error('offline');
|
||||
return mode !== 'incomplete';
|
||||
}},
|
||||
isAgendaSelected: () => queue === 'agenda',
|
||||
hasMore: () => mode === 'incomplete',
|
||||
hasCheckpoint: () => true,
|
||||
resume: () => {{ opens += 1; return true; }},
|
||||
start: () => {{ opens += 1; return true; }},
|
||||
announce: message => messages.push(message),
|
||||
}});
|
||||
return {{result:await launcher.open(), opens, messages}};
|
||||
}}
|
||||
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
|
||||
process.stdout.write(JSON.stringify(results))
|
||||
);
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
{"result": "incomplete", "opens": 0, "messages": [
|
||||
"Agenda check paused. Retry to check older assigned deadlines."
|
||||
]},
|
||||
{"result": "incomplete", "opens": 0, "messages": [
|
||||
"Agenda check paused. Retry to check older assigned deadlines."
|
||||
]},
|
||||
{"result": "cancelled", "opens": 0, "messages": []},
|
||||
]
|
||||
|
||||
|
||||
def test_protect_today_route_is_an_explicit_agenda_action():
|
||||
@@ -239,16 +452,95 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
||||
const storage = {{
|
||||
getItem:key => values.get(key) || null,
|
||||
setItem:(key, value) => values.set(key, value),
|
||||
removeItem:key => values.delete(key),
|
||||
}};
|
||||
let login = 'timmy';
|
||||
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
|
||||
}});
|
||||
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
|
||||
storage, getLogin:() => login, key:'stackchain.today-session.v1',
|
||||
}});
|
||||
let mode = 'agenda';
|
||||
let items = [
|
||||
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
|
||||
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
|
||||
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
|
||||
];
|
||||
const opened = [];
|
||||
const makeSession = () => buildMyWork.createWorkSession({{
|
||||
getItems:() => items, getFilter:() => 'all',
|
||||
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
|
||||
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
|
||||
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
|
||||
}});
|
||||
const first = makeSession();
|
||||
first.start();
|
||||
first.next();
|
||||
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
|
||||
const todayWhileRunning = first.checkpointed();
|
||||
login = 'alexander';
|
||||
const hiddenFromOtherAccount = makeSession().resumable();
|
||||
login = 'timmy';
|
||||
items = items.filter(item => item.number !== 2);
|
||||
const resumedSession = makeSession();
|
||||
const resumed = resumedSession.resume();
|
||||
resumedSession.end();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
|
||||
agendaCleared:!values.has('stackchain.agenda-session.v1'),
|
||||
today:values.get('stackchain.today-session.v1'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
state = json.loads(result.stdout)
|
||||
assert state["saved"] == {
|
||||
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
|
||||
}
|
||||
assert state["todayWhileRunning"] is False
|
||||
assert state["hiddenFromOtherAccount"] is False
|
||||
assert state["resumed"] is True
|
||||
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
|
||||
assert state["agendaCleared"] is True
|
||||
assert state["today"] == "preserve-today"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/agenda-session-launcher.js"></script>' in html
|
||||
assert "openAgenda: openAgendaSession" in html
|
||||
assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
|
||||
assert "discover: completeAgendaIssues" in html
|
||||
assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
|
||||
assert "key: 'stackchain.agenda-session.v1'" in html
|
||||
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
||||
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
||||
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
||||
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
||||
assert "window.location.hash = '#/my-work/agenda'" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
|
||||
html = await dashboard()
|
||||
|
||||
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
|
||||
assert "selectedWorkFilter === 'agenda' ? 'all'" in html
|
||||
assert "startWorkSession.textContent = selectedWorkFilter === 'agenda' ? 'Start Agenda' : 'Start work'" in html
|
||||
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
|
||||
assert "const sessionItems = workSession.items()" in html
|
||||
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
|
||||
assert "checkpointEnabled: () => selectedWorkFilter === 'today'" in html
|
||||
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
|
||||
@@ -3220,8 +3512,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
|
||||
assert 'id="end-today-session"' in html
|
||||
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
|
||||
assert 'getLogin: () => confirmedOwnerLogin' in html
|
||||
assert 'checkpoint: sessionCheckpoint' in html
|
||||
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
|
||||
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
|
||||
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
|
||||
assert "qs('#resume-today-session').addEventListener('click'" in html
|
||||
assert "qs('#end-today-session').addEventListener('click'" in html
|
||||
assert "workSession.resume(item)" in html
|
||||
@@ -4693,6 +4985,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
|
||||
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="mute-update-next" type="button" hidden' in html
|
||||
assert ">Mute future updates & next</button>" in html
|
||||
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
|
||||
assert "async function muteNotification(notificationId)" in html
|
||||
assert "'/mute', { method: 'POST'" in html
|
||||
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
|
||||
assert "Future updates are muted; current item is still unread" in html
|
||||
assert "qs('#mute-update-next').disabled = offline;" in html
|
||||
assert ".update-sheet-actions #mute-update-next" in html
|
||||
|
||||
|
||||
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
|
||||
@@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
|
||||
"has_more": True,
|
||||
}
|
||||
assert requested == [2]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
|
||||
requested = []
|
||||
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
requested.append(deadline_seconds)
|
||||
return {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.json() == {
|
||||
"items": [
|
||||
{"id": 1, "title": "Newest update"},
|
||||
{"id": 51, "title": "Older update"},
|
||||
],
|
||||
"total": 2,
|
||||
"complete": True,
|
||||
}
|
||||
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
|
||||
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
|
||||
async def snapshot_loader(*, deadline_seconds):
|
||||
raise failure
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/notifications/snapshot")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert response.headers["retry-after"] == "1"
|
||||
assert response.json() == {
|
||||
"error": "Complete unread updates are temporarily unavailable. Please retry."
|
||||
}
|
||||
@@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
|
||||
assert restored == [42]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def mute(thread_id):
|
||||
calls.append(("mute", thread_id))
|
||||
return {"id": thread_id, "muted": True}
|
||||
|
||||
async def fail_read(thread_id):
|
||||
calls.append(("read", thread_id))
|
||||
raise httpx.HTTPError("unavailable")
|
||||
|
||||
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
|
||||
monkeypatch.setattr(main, "mark_notification_read", fail_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post("/api/v1/notifications/42/mute")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"id": 42,
|
||||
"muted": True,
|
||||
"status": "unread",
|
||||
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
|
||||
}
|
||||
assert calls == [("mute", 42), ("read", 42)]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-readiness.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -46,6 +46,11 @@ const context = {{
|
||||
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
|
||||
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
|
||||
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
|
||||
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
|
||||
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
|
||||
get: async id => state.sharedRecords[id] || null,
|
||||
delete: async id => {{ delete state.sharedRecords[id]; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -145,7 +150,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -154,14 +159,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -169,7 +174,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -177,14 +182,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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" 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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -193,24 +198,77 @@ 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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" 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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" 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-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
def test_image_share_target_stages_one_supported_image_and_redirects_to_capture():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const image = new Blob(['pixels'], {type:'image/png'});
|
||||
Object.defineProperty(image, 'name', {value:'bug.png'});
|
||||
const form = new FormData();
|
||||
form.append('title', 'Broken checkout');
|
||||
form.append('text', 'Steps to reproduce');
|
||||
form.append('url', 'https://example.test/checkout');
|
||||
form.append('image', image, 'bug.png');
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
const [id, record] = Object.entries(state.sharedRecords)[0];
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), id,
|
||||
filename:record.filename, contentType:record.contentType, size:record.blob.size,
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout",
|
||||
"id": "shared-image",
|
||||
"filename": "bug.png",
|
||||
"contentType": "image/png",
|
||||
"size": 6,
|
||||
}
|
||||
|
||||
|
||||
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
const form = new FormData();
|
||||
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
|
||||
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
|
||||
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
|
||||
Object.defineProperty(request, 'mode', {value:'navigate'});
|
||||
const response = await dispatch('fetch', request);
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": 303,
|
||||
"location": "/dashboard/?launch=new&shared=multiple",
|
||||
"records": [],
|
||||
}
|
||||
|
||||
|
||||
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -703,7 +761,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -733,6 +791,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/shared-image-capture.js",
|
||||
"/dashboard/static/draft-filing-session.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
@@ -783,10 +842,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/work-route.js",
|
||||
"/dashboard/static/task-overlay-history.js",
|
||||
"/dashboard/static/context-poller.js",
|
||||
"/dashboard/static/live-data-status.js",
|
||||
"/dashboard/static/mobile-task-dock.js",
|
||||
"/dashboard/static/mobile-work-entry.js",
|
||||
"/dashboard/static/mobile-queue-launcher.js",
|
||||
"/dashboard/static/update-triage-session.js",
|
||||
"/dashboard/static/update-triage-launcher.js",
|
||||
"/dashboard/static/agenda-session-launcher.js",
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/mobile-device-setup.js",
|
||||
"/dashboard/static/mobile-search-viewport.js",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1] / "frontend" / "shared-image-capture.js"
|
||||
|
||||
|
||||
def test_shared_image_is_consumed_once_and_restored_into_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
const calls=[];
|
||||
const store={{
|
||||
get:async id=>({{filename:'bug.png',contentType:'image/png',blob:{{size:6,type:'image/png'}}}}),
|
||||
delete:async id=>calls.push(['delete',id]),
|
||||
}};
|
||||
shared.consume({{
|
||||
marker:'image', store,
|
||||
restore:value=>calls.push(['restore',value.filename]),
|
||||
status:message=>calls.push(['status',message]),
|
||||
}}).then(first=>shared.consume({{marker:'',store,restore:()=>calls.push(['restore-again'])}}).then(second=>{{
|
||||
process.stdout.write(JSON.stringify({{first,second,calls}}));
|
||||
}}));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"first": True,
|
||||
"second": False,
|
||||
"calls": [
|
||||
["restore", "bug.png"],
|
||||
["delete", "shared-image"],
|
||||
["status", "Shared screenshot ready to file with this issue."],
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_shared_image_reports_error_without_touching_open_capture():
|
||||
script = f"""
|
||||
const shared = require({json.dumps(str(MODULE))});
|
||||
const calls=[];
|
||||
shared.consume({{
|
||||
marker:'unsupported', store:{{get:async()=>null,delete:async()=>calls.push('delete')}},
|
||||
restore:()=>calls.push('restore'), status:message=>calls.push(message),
|
||||
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
|
||||
"""
|
||||
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"result": False,
|
||||
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
|
||||
}
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v99';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v100';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v99" in source
|
||||
assert "stackchain-dashboard-shell-v100" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.dashboard_bundle import dashboard
|
||||
|
||||
|
||||
SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js"
|
||||
|
||||
|
||||
def run_session(script):
|
||||
source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script
|
||||
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let login = 'timmy';
|
||||
let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id}));
|
||||
const opened = [], progress = [];
|
||||
const options = {
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=>login, getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
};
|
||||
let session = createSession(options);
|
||||
session.start();
|
||||
session.keepUnreadAndNext();
|
||||
const saved = JSON.parse(values.get('stackchain.update-triage.v1'));
|
||||
session = createSession(options);
|
||||
const resumable = session.resumable();
|
||||
session.resume();
|
||||
login = 'other';
|
||||
const isolated = session.resumable();
|
||||
process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated}));
|
||||
""")
|
||||
|
||||
assert result["opened"] == [1, 2, 2]
|
||||
assert result["progress"] == [
|
||||
{"index": 1, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
{"index": 2, "total": 3},
|
||||
]
|
||||
assert result["saved"] == {
|
||||
"version": 1,
|
||||
"login": "timmy",
|
||||
"identities": ["1", "2", "3"],
|
||||
"current": "2",
|
||||
"completed": ["1"],
|
||||
}
|
||||
assert result["resumable"] is True
|
||||
assert result["isolated"] is False
|
||||
|
||||
|
||||
def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals():
|
||||
result = run_session("""
|
||||
const values = new Map();
|
||||
let items = [1,2,3].map(notification_id => ({notification_id}));
|
||||
const opened = [], progress = [];
|
||||
const session = createSession({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
|
||||
getLogin:()=> 'timmy', getItems:()=>items,
|
||||
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
|
||||
});
|
||||
session.start();
|
||||
items = [1,3,4].map(notification_id => ({notification_id}));
|
||||
session.completeAndNext();
|
||||
session.completeAndNext();
|
||||
process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null}));
|
||||
""")
|
||||
|
||||
assert result == {
|
||||
"opened": [1, 3, "done"],
|
||||
"progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}],
|
||||
"stored": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<script src="static/update-triage-session.js"></script>' in html
|
||||
assert 'id="update-triage-progress"' in html
|
||||
assert 'id="keep-update-unread" type="button">Keep unread & next</button>' in html
|
||||
assert "openUpdates: openUpdateTriage" in html
|
||||
assert "updateTriage.acceptCompleted()" in html
|
||||
assert "updateTriage.keepUnreadAndNext()" in html
|
||||
assert "updateTriage.reconcile()" in html
|
||||
assert ".update-triage-progress" in html
|
||||
@@ -79,15 +79,41 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
|
||||
assert manifest.json()["start_url"] == "./"
|
||||
assert manifest.json()["scope"] == "./"
|
||||
assert manifest.json()["share_target"] == {
|
||||
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
|
||||
"params": {"title": "title", "text": "text", "url": "url"},
|
||||
"action": "./", "method": "POST", "enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title", "text": "text", "url": "url",
|
||||
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
|
||||
},
|
||||
}
|
||||
assert manifest.json()["shortcuts"] == [
|
||||
{
|
||||
"name": "Continue work", "short_name": "Continue",
|
||||
"description": "Resume the highest-priority mobile work flow.",
|
||||
"url": "./?launch=continue",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
{
|
||||
"name": "New issue", "short_name": "New",
|
||||
"description": "Capture work now and file it online or offline.",
|
||||
"url": "./?launch=new",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
{
|
||||
"name": "Open Agenda", "short_name": "Agenda",
|
||||
"description": "Check every assigned deadline and continue the Agenda session.",
|
||||
"url": "./?launch=agenda",
|
||||
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
},
|
||||
]
|
||||
assert worker.status_code == 200
|
||||
assert worker.headers["content-type"].startswith("application/javascript")
|
||||
assert worker.headers["service-worker-allowed"] == "/"
|
||||
assert "request.url.includes('/api/')" in worker.text
|
||||
assert "request.method !== 'GET'" in worker.text
|
||||
assert "new URL('./', self.location.href).pathname" in worker.text
|
||||
assert "acceptSharedContent(request)" in worker.text
|
||||
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
Reference in new issue
Block a user