Compare commits
30
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 | ||
|
|
1fc70f6890 | ||
|
|
523968f5a3 | ||
|
|
af27bb06e0 | ||
|
|
4410af09a6 | ||
|
|
880f60831a | ||
|
|
e860d16d29 | ||
|
|
abf8e23685 |
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();
|
||||
|
||||
+15
-3
@@ -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; }
|
||||
@@ -40,7 +50,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
|
||||
.device-setup-step p { margin:5px 0 0; }
|
||||
.device-setup-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
|
||||
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; }
|
||||
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour, #push-deadline-days { min-height:44px; }
|
||||
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
|
||||
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
|
||||
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
|
||||
@@ -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; }
|
||||
|
||||
+260
-38
@@ -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,
|
||||
@@ -1148,8 +1226,12 @@
|
||||
}
|
||||
function updateWorkSessionActions() {
|
||||
const active = workSession.active();
|
||||
qs('#start-work-session').hidden = active;
|
||||
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
|
||||
const startWorkSession = qs('#start-work-session');
|
||||
startWorkSession.hidden = active;
|
||||
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());
|
||||
@@ -1157,11 +1239,14 @@
|
||||
}
|
||||
|
||||
const workSession = createWorkSession({
|
||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkMilestone,
|
||||
checkpoint: sessionCheckpoint,
|
||||
checkpointEnabled: () => selectedWorkFilter === 'today',
|
||||
getItems: () => selectedWorkFilter === 'today' ? todayMyWork :
|
||||
selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork,
|
||||
getFilter: () => selectedWorkFilter === 'today' ? 'all' :
|
||||
selectedWorkFilter === 'agenda' ? 'all' : selectedWorkFilter,
|
||||
getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone,
|
||||
checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint,
|
||||
checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter),
|
||||
checkpointedEnabled: () => selectedWorkFilter === 'today',
|
||||
onOpen: openWorkSessionItem,
|
||||
onProgress: state => {
|
||||
updateWorkSessionActions();
|
||||
@@ -1178,12 +1263,35 @@
|
||||
button.hidden = !workSession.checkpointed();
|
||||
});
|
||||
},
|
||||
onFinish: () => todayRecapView.finish(selectedWorkFilter),
|
||||
onFinish: () => {
|
||||
updateWorkSessionActions();
|
||||
if (selectedWorkFilter === 'agenda') {
|
||||
closeOpenWorkSheets();
|
||||
window.location.hash = '#/my-work/agenda';
|
||||
qs('#my-work-action-status').textContent = 'Agenda complete.';
|
||||
return;
|
||||
}
|
||||
todayRecapView.finish(selectedWorkFilter);
|
||||
},
|
||||
});
|
||||
function selectTodayWork() {
|
||||
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;
|
||||
@@ -2132,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;
|
||||
@@ -2307,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() {
|
||||
@@ -3605,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();
|
||||
}
|
||||
|
||||
@@ -4000,6 +4126,7 @@
|
||||
} else if (!eventsFreshness && snapshot.freshness?.revalidating) {
|
||||
setEventStreamStatus('Refreshing · showing recent snapshot');
|
||||
}
|
||||
renderLiveDataStatus(snapshot.freshness || {});
|
||||
}
|
||||
|
||||
|
||||
@@ -5378,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', () => {
|
||||
@@ -5450,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.';
|
||||
@@ -5464,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;
|
||||
}
|
||||
@@ -5474,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;
|
||||
}
|
||||
@@ -5681,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');
|
||||
@@ -5763,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);
|
||||
});
|
||||
}
|
||||
@@ -5868,12 +6079,13 @@
|
||||
qs('#save-today-plan').addEventListener('click', event => commitPlanToday(false, event.currentTarget));
|
||||
qs('#save-and-start-today').addEventListener('click', event => commitPlanToday(true, event.currentTarget));
|
||||
qs('#start-work-session').addEventListener('click', () => {
|
||||
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
|
||||
const sessionItems = workSession.items();
|
||||
if (!sessionItems.length) {
|
||||
qs('#my-work-action-status').textContent = 'No visible work to start.';
|
||||
return;
|
||||
}
|
||||
if (selectedWorkFilter === 'today') runTodayTransition('start');
|
||||
else if (selectedWorkFilter === 'agenda' && agendaSessionCheckpoint.read()) workSession.resume();
|
||||
else workSession.start();
|
||||
});
|
||||
qs('#resume-today-session').addEventListener('click', () => resumeTodaySession());
|
||||
@@ -6044,15 +6256,14 @@
|
||||
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))
|
||||
);
|
||||
const selectedCount = button.querySelector('[data-work-count]').textContent;
|
||||
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
|
||||
renderMyWork();
|
||||
updateWorkSessionActions();
|
||||
updateWorkPaginationControls();
|
||||
if (selectedWorkFilter === 'agenda') completeAgendaIssues();
|
||||
if (!preserveRoute) workRoute.queue(filter);
|
||||
@@ -6083,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();
|
||||
});
|
||||
@@ -6104,6 +6315,13 @@
|
||||
qs('#device-setup-deadline-hour').addEventListener('change', event => {
|
||||
qs('#push-deadline-hour').value = event.target.value;
|
||||
});
|
||||
qs('#push-deadline-days').addEventListener('change', event => {
|
||||
qs('#device-setup-deadline-days').value = event.target.value;
|
||||
if (qs('#push-deadlines').checked) pushController?.changeDeadline();
|
||||
});
|
||||
qs('#device-setup-deadline-days').addEventListener('change', event => {
|
||||
qs('#push-deadline-days').value = event.target.value;
|
||||
});
|
||||
let pushControllerReady = Promise.resolve(null);
|
||||
if ('serviceWorker' in navigator) {
|
||||
pushControllerReady = navigator.serviceWorker.register('service-worker.js').then(async () => {
|
||||
@@ -6114,6 +6332,7 @@
|
||||
deadlineControl:qs('#push-deadlines'),
|
||||
deadlineStatus:qs('#push-deadline-status'),
|
||||
deadlineHour:qs('#push-deadline-hour'),
|
||||
deadlineDays:qs('#push-deadline-days'),
|
||||
notification:window.Notification,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
fetchJson:fetchReviewJson,
|
||||
@@ -6121,11 +6340,11 @@
|
||||
await controller.init();
|
||||
pushController = controller;
|
||||
qs('#device-setup-deadline-hour').value = qs('#push-deadline-hour').value;
|
||||
qs('#device-setup-deadline-days').value = qs('#push-deadline-days').value;
|
||||
return controller;
|
||||
}).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 {
|
||||
@@ -6169,6 +6388,7 @@
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
qs('#push-deadline-hour').value = qs('#device-setup-deadline-hour').value;
|
||||
qs('#push-deadline-days').value = qs('#device-setup-deadline-days').value;
|
||||
await controller.enableDeadline();
|
||||
},
|
||||
});
|
||||
@@ -6179,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);
|
||||
|
||||
+28
-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>
|
||||
@@ -87,6 +101,8 @@
|
||||
<div class="device-setup-deadline-controls">
|
||||
<label for="device-setup-deadline-hour">Local hour</label>
|
||||
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
|
||||
<label for="device-setup-deadline-days">Warn me</label>
|
||||
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select>
|
||||
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -139,6 +155,7 @@
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
|
||||
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
|
||||
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></select></label>
|
||||
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
|
||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||
@@ -732,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">
|
||||
@@ -776,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>
|
||||
@@ -984,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>
|
||||
@@ -998,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>
|
||||
@@ -1049,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)];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createPushNotifications = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays, notification, serviceWorker, fetchJson,
|
||||
}) {
|
||||
let configuration = null;
|
||||
|
||||
@@ -10,12 +10,18 @@
|
||||
return `${String(Number(value)).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
function enabledDeadlineText(hour, days) {
|
||||
const value = Number(days ?? 2);
|
||||
const horizon = value === 0 ? 'due today' : `next ${value} days`;
|
||||
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
|
||||
}
|
||||
|
||||
function deadlineReadiness() {
|
||||
if (!configuration?.available || deadlineControl?.disabled) {
|
||||
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
|
||||
}
|
||||
return configuration.deadline_enabled
|
||||
? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`}
|
||||
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
|
||||
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
|
||||
}
|
||||
|
||||
@@ -84,6 +90,7 @@
|
||||
async function changeDeadline() {
|
||||
deadlineControl.disabled = true;
|
||||
if (deadlineHour) deadlineHour.disabled = true;
|
||||
if (deadlineDays) deadlineDays.disabled = true;
|
||||
try {
|
||||
const registration = await serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
@@ -95,16 +102,18 @@
|
||||
}
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
|
||||
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
|
||||
await fetchJson('api/v1/push-subscription/deadlines', {
|
||||
method:'PUT',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}),
|
||||
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
|
||||
});
|
||||
configuration.deadline_enabled = deadlineControl.checked;
|
||||
configuration.reminder_hour = reminderHour;
|
||||
configuration.reminder_days = reminderDays;
|
||||
configuration.timezone = timezone;
|
||||
deadlineStatus.textContent = deadlineControl.checked
|
||||
? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.`
|
||||
? enabledDeadlineText(reminderHour, reminderDays)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -114,6 +123,7 @@
|
||||
} finally {
|
||||
deadlineControl.disabled = false;
|
||||
if (deadlineHour) deadlineHour.disabled = false;
|
||||
if (deadlineDays) deadlineDays.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,11 +146,12 @@
|
||||
control.checked = Boolean(configuration.subscribed);
|
||||
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
|
||||
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
|
||||
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
|
||||
status.textContent = configuration.subscribed
|
||||
? 'New update notifications enabled for this device.'
|
||||
: 'New update notifications are off for this device.';
|
||||
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
|
||||
? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`
|
||||
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
|
||||
: 'Deadline reminders are off for this device.';
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+66
-11
@@ -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
|
||||
|
||||
@@ -403,15 +403,39 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict:
|
||||
async def assigned_issue_snapshot(
|
||||
*,
|
||||
limit: int = 50,
|
||||
max_pages: int = 20,
|
||||
max_concurrency: int = 4,
|
||||
deadline_seconds: float = 5.0,
|
||||
) -> dict:
|
||||
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
|
||||
items = []
|
||||
for page in range(1, max_pages + 1):
|
||||
result = await work_page("issue", page, limit)
|
||||
items.extend(result["items"])
|
||||
if not result["has_more"]:
|
||||
return {"items": items, "complete": True}
|
||||
return {"items": [], "complete": False}
|
||||
async with asyncio.timeout(deadline_seconds):
|
||||
first = await work_page("issue", 1, limit)
|
||||
page_count = max(1, (first["total"] + limit - 1) // limit)
|
||||
if page_count > max_pages:
|
||||
raise ValueError("Assigned issue snapshot exceeds the scan limit")
|
||||
semaphore = asyncio.Semaphore(max(1, max_concurrency))
|
||||
|
||||
async def load(page: int) -> dict:
|
||||
async with semaphore:
|
||||
return await work_page("issue", page, limit)
|
||||
|
||||
remaining = await asyncio.gather(
|
||||
*(load(page) for page in range(2, page_count + 1))
|
||||
)
|
||||
pages = [first, *remaining]
|
||||
if any(page.get("total") != first["total"] for page in pages[1:]):
|
||||
raise ValueError("Assigned issue pagination changed during the scan")
|
||||
items = [item for page in pages for item in page["items"]]
|
||||
issue_ids = [
|
||||
item.get("id") for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
|
||||
]
|
||||
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
|
||||
raise ValueError("Assigned issue snapshot has an incomplete issue set")
|
||||
return {"items": items, "complete": True}
|
||||
|
||||
|
||||
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
|
||||
@@ -892,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):
|
||||
@@ -916,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 {}
|
||||
@@ -988,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,
|
||||
}
|
||||
|
||||
@@ -1034,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):
|
||||
|
||||
+67
@@ -394,6 +394,7 @@ class DeadlineReminderPayload(BaseModel):
|
||||
enabled: bool
|
||||
timezone: str = Field(min_length=1, max_length=64)
|
||||
reminder_hour: int = Field(default=9, ge=0, le=23)
|
||||
reminder_days: Literal[0, 2, 7] = 2
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
@@ -1011,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)}"
|
||||
@@ -1939,6 +1951,7 @@ async def push_status(request: Request):
|
||||
"deadline_enabled": preferences["enabled"],
|
||||
"timezone": preferences["timezone"],
|
||||
"reminder_hour": preferences["reminder_hour"],
|
||||
"reminder_days": preferences["reminder_days"],
|
||||
}
|
||||
|
||||
|
||||
@@ -2007,11 +2020,13 @@ async def update_deadline_reminders(payload: DeadlineReminderPayload, request: R
|
||||
enabled=payload.enabled,
|
||||
timezone=payload.timezone,
|
||||
reminder_hour=payload.reminder_hour,
|
||||
reminder_days=payload.reminder_days,
|
||||
)
|
||||
return {
|
||||
"deadline_enabled": payload.enabled,
|
||||
"timezone": payload.timezone,
|
||||
"reminder_hour": payload.reminder_hour,
|
||||
"reminder_days": payload.reminder_days,
|
||||
}
|
||||
|
||||
|
||||
@@ -3577,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),
|
||||
@@ -3670,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,
|
||||
|
||||
@@ -323,7 +323,7 @@ async def _dispatch_deadline_reminders_unlocked(
|
||||
due_counts = {}
|
||||
for device in eligible_devices:
|
||||
local_now = current.astimezone(ZoneInfo(device.timezone))
|
||||
local_cutoff = local_now.date() + timedelta(days=2)
|
||||
local_cutoff = local_now.date() + timedelta(days=device.reminder_days)
|
||||
due_count = sum(due_day <= local_cutoff for due_day in due_days)
|
||||
if due_count:
|
||||
due_counts[device.session_id] = due_count
|
||||
|
||||
@@ -28,6 +28,7 @@ class DeadlineReminderDevice:
|
||||
subscription: dict
|
||||
timezone: str
|
||||
reminder_hour: int
|
||||
reminder_days: int
|
||||
delivered_local_day: str | None
|
||||
|
||||
|
||||
@@ -92,6 +93,7 @@ class PushSubscriptionStore:
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
reminder_days INTEGER NOT NULL DEFAULT 2,
|
||||
delivered_local_day TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
|
||||
ON DELETE CASCADE
|
||||
@@ -131,6 +133,14 @@ class PushSubscriptionStore:
|
||||
DROP TABLE push_dispatch_lease_legacy;
|
||||
"""
|
||||
)
|
||||
preference_columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
|
||||
}
|
||||
if "reminder_days" not in preference_columns:
|
||||
connection.execute(
|
||||
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
|
||||
)
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def _connect(self):
|
||||
@@ -192,24 +202,26 @@ class PushSubscriptionStore:
|
||||
).fetchone() is not None
|
||||
|
||||
def set_deadline_preferences(
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
|
||||
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int,
|
||||
reminder_days: int = 2,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""INSERT INTO push_deadline_preferences(
|
||||
session_id, enabled, timezone, reminder_hour
|
||||
) VALUES (?, ?, ?, ?)
|
||||
session_id, enabled, timezone, reminder_hour, reminder_days
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
enabled = excluded.enabled,
|
||||
timezone = excluded.timezone,
|
||||
reminder_hour = excluded.reminder_hour""",
|
||||
(session_id, int(enabled), timezone, reminder_hour),
|
||||
reminder_hour = excluded.reminder_hour,
|
||||
reminder_days = excluded.reminder_days""",
|
||||
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
|
||||
)
|
||||
|
||||
def deadline_preferences(self, session_id: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT enabled, timezone, reminder_hour
|
||||
"""SELECT enabled, timezone, reminder_hour, reminder_days
|
||||
FROM push_deadline_preferences WHERE session_id = ?""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
@@ -217,19 +229,20 @@ class PushSubscriptionStore:
|
||||
"enabled": bool(row[0]) if row else False,
|
||||
"timezone": row[1] if row else "UTC",
|
||||
"reminder_hour": row[2] if row else 9,
|
||||
"reminder_days": row[3] if row else 2,
|
||||
}
|
||||
|
||||
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""SELECT s.session_id, s.subscription_json, p.timezone,
|
||||
p.reminder_hour, p.delivered_local_day
|
||||
p.reminder_hour, p.reminder_days, p.delivered_local_day
|
||||
FROM push_subscriptions s
|
||||
JOIN push_deadline_preferences p ON p.session_id = s.session_id
|
||||
WHERE p.enabled = 1 ORDER BY s.session_id"""
|
||||
).fetchall()
|
||||
return [
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
|
||||
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4], row[5])
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -85,6 +85,48 @@ async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_pa
|
||||
assert sent == {"tokyo": 2, "la": 1}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_uses_each_devices_confirmed_horizon(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for device, reminder_days in (("today", 0), ("week", 7)):
|
||||
store.upsert(device, {
|
||||
"endpoint": f"https://push.example/{device}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_deadline_preferences(
|
||||
device,
|
||||
enabled=True,
|
||||
timezone="UTC",
|
||||
reminder_hour=9,
|
||||
reminder_days=reminder_days,
|
||||
)
|
||||
|
||||
async def assigned():
|
||||
return {"complete": True, "items": [
|
||||
{"id": 1, "due_date": "2026-08-13"},
|
||||
{"id": 2, "due_date": "2026-08-15"},
|
||||
{"id": 3, "due_date": "2026-08-20"},
|
||||
{"id": 4, "due_date": "2026-08-21"},
|
||||
]}
|
||||
|
||||
sent = {}
|
||||
|
||||
async def send(subscription, payload):
|
||||
device = subscription["endpoint"].rsplit("/", 1)[-1]
|
||||
sent[device] = json.loads(payload)["deadline_count"]
|
||||
|
||||
delivered = await dispatch_deadline_reminders(
|
||||
store,
|
||||
PushConfiguration("public", "private", "mailto:ops@example.com"),
|
||||
assigned,
|
||||
send,
|
||||
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert delivered == 2
|
||||
assert sent == {"today": 1, "week": 3}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -194,29 +236,126 @@ def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_pa
|
||||
"enabled": True,
|
||||
"timezone": "Europe/London",
|
||||
"reminder_hour": 8,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
|
||||
database = tmp_path / "push.sqlite3"
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.executescript("""
|
||||
CREATE TABLE push_subscriptions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
subscription_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE push_deadline_preferences (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
reminder_hour INTEGER NOT NULL DEFAULT 9,
|
||||
delivered_local_day TEXT
|
||||
);
|
||||
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
|
||||
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
|
||||
""")
|
||||
|
||||
store = PushSubscriptionStore(database)
|
||||
|
||||
assert store.deadline_preferences("device-a")["reminder_days"] == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
|
||||
pages = {
|
||||
1: {"items": [{"id": 1}], "has_more": True},
|
||||
2: {"items": [{"id": 2}], "has_more": False},
|
||||
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
|
||||
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
|
||||
}
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
assert stream == "issue"
|
||||
assert limit == 50
|
||||
assert limit == 1
|
||||
return pages[page]
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot() == {
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
|
||||
"items": [{"id": 1}, {"id": 2}],
|
||||
"complete": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
|
||||
active = 0
|
||||
peak = 0
|
||||
remaining_started = asyncio.Event()
|
||||
|
||||
async def work_page(stream, page, limit):
|
||||
nonlocal active, peak
|
||||
assert stream == "issue"
|
||||
assert limit == 1
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 5, "has_more": True}
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
if active == 2:
|
||||
remaining_started.set()
|
||||
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
|
||||
await asyncio.sleep(0)
|
||||
active -= 1
|
||||
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
|
||||
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
|
||||
"complete": True,
|
||||
}
|
||||
assert peak == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"remaining",
|
||||
[
|
||||
{"items": [{"id": 2}], "total": 3, "has_more": True},
|
||||
{"items": [{"id": 1}], "total": 2, "has_more": False},
|
||||
],
|
||||
)
|
||||
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
|
||||
monkeypatch, remaining
|
||||
):
|
||||
async def work_page(_stream, page, _limit):
|
||||
if page == 1:
|
||||
return {"items": [{"id": 1}], "total": 2, "has_more": True}
|
||||
return remaining
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete|changed"):
|
||||
await gitea_proxy.assigned_issue_snapshot(limit=1)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def work_page(_stream, _page, _limit):
|
||||
try:
|
||||
await asyncio.sleep(60)
|
||||
finally:
|
||||
cancelled.set()
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
|
||||
assert cancelled.is_set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_competing_workers_send_one_deadline_digest(tmp_path):
|
||||
path = tmp_path / "push.sqlite3"
|
||||
|
||||
@@ -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()
|
||||
|
||||
+367
-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():
|
||||
@@ -195,6 +408,141 @@ process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-1
|
||||
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
|
||||
|
||||
|
||||
def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
|
||||
const checkpoint = {{
|
||||
read:() => null,
|
||||
save:() => {{ throw new Error('Agenda must not save Today'); }},
|
||||
clear:() => {{ throw new Error('Agenda must not clear Today'); }},
|
||||
}};
|
||||
const active = [
|
||||
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
|
||||
{{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
|
||||
{{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
|
||||
{{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
|
||||
];
|
||||
const opened = [];
|
||||
let finished = 0;
|
||||
const session = buildMyWork.createWorkSession({{
|
||||
getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
|
||||
getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
|
||||
onOpen:item => opened.push(item.title), onProgress:() => {{}},
|
||||
onFinish:() => {{ finished += 1; }},
|
||||
}});
|
||||
const started = session.start();
|
||||
session.next();
|
||||
session.next();
|
||||
const completed = session.next();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"started": True,
|
||||
"completed": False,
|
||||
"opened": ["Overdue", "Today", "Tomorrow"],
|
||||
"finished": 1,
|
||||
"active": False,
|
||||
"today": "preserve-today",
|
||||
}
|
||||
|
||||
|
||||
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 "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: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
|
||||
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
|
||||
script = f"""
|
||||
@@ -1475,7 +1823,7 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
|
||||
assert 'id="save-issue-milestone"' in html
|
||||
assert 'class="pill milestone-badge"' in html
|
||||
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
|
||||
assert 'getMilestone: () => selectedWorkMilestone' in html
|
||||
assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
|
||||
assert 'buildMyWork.replaceIssueMilestone(' in html
|
||||
|
||||
|
||||
@@ -3164,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
|
||||
@@ -4637,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))});
|
||||
@@ -5821,7 +6184,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
|
||||
assert '<script src="static/my-work.js"></script>' in html
|
||||
assert "buildMyWork(data)" in html
|
||||
assert "markMyWorkStale()" in html
|
||||
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
|
||||
assert "const sessionItems = workSession.items()" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@ const deadlineControl = {
|
||||
addEventListener:(_name, callback) => state.deadlineChange = callback,
|
||||
};
|
||||
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
|
||||
const deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = callback};
|
||||
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
|
||||
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
|
||||
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
|
||||
@@ -27,7 +28,7 @@ const registration = {pushManager:{
|
||||
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
|
||||
}};
|
||||
const feature = createPushNotifications({
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour,
|
||||
control, status, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
|
||||
serviceWorker: {ready:Promise.resolve(registration)},
|
||||
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
|
||||
@@ -80,7 +81,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 9
|
||||
assert isinstance(body["timezone"], str) and body["timezone"]
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 2 days."
|
||||
|
||||
|
||||
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
|
||||
@@ -100,7 +101,35 @@ process.stdout.write(JSON.stringify(state));
|
||||
body = json.loads(result["requests"][-1][2])
|
||||
assert body["enabled"] is True
|
||||
assert body["reminder_hour"] == 8
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
|
||||
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time, next 2 days."
|
||||
|
||||
|
||||
def test_deadline_setup_persists_and_restores_confirmed_horizon():
|
||||
saved = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
deadlineDays.value = '7';
|
||||
await feature.changeDeadline();
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
""")
|
||||
|
||||
assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7
|
||||
assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days."
|
||||
|
||||
restored = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()}));
|
||||
""")
|
||||
assert restored == {
|
||||
"days": "0",
|
||||
"readiness": {
|
||||
"state": "complete",
|
||||
"detail": "Deadline reminders enabled for 17:00 local time, due today.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
|
||||
@@ -112,7 +141,7 @@ process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineCo
|
||||
""")
|
||||
assert restored == {
|
||||
"hour": "17", "checked": True,
|
||||
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
|
||||
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time, next 2 days."},
|
||||
}
|
||||
|
||||
denied = run_scenario("""
|
||||
@@ -142,6 +171,8 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
|
||||
assert 'id="push-update-status"' in html
|
||||
assert 'id="push-deadlines"' in html
|
||||
assert 'id="push-deadline-hour"' in html
|
||||
assert 'id="push-deadline-days"' in html
|
||||
assert 'id="device-setup-deadline-days"' in html
|
||||
assert 'id="push-deadline-status"' in html
|
||||
assert '<script src="static/push-notifications.js"></script>' in html
|
||||
assert "createPushNotifications({" in dashboard
|
||||
|
||||
@@ -980,6 +980,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"deadline_enabled": False,
|
||||
"timezone": "UTC",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 2,
|
||||
}
|
||||
assert await main.subscribe_push(payload, request) == {"subscribed": True}
|
||||
assert (await main.push_status(request))["subscribed"] is True
|
||||
@@ -1002,15 +1003,17 @@ async def test_authenticated_device_can_enable_deadline_reminders(tmp_path, monk
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload = main.DeadlineReminderPayload(
|
||||
enabled=True, timezone="America/New_York", reminder_hour=9
|
||||
enabled=True, timezone="America/New_York", reminder_hour=9, reminder_days=7
|
||||
)
|
||||
|
||||
assert await main.update_deadline_reminders(payload, request) == {
|
||||
"deadline_enabled": True,
|
||||
"timezone": "America/New_York",
|
||||
"reminder_hour": 9,
|
||||
"reminder_days": 7,
|
||||
}
|
||||
assert store.deadline_preferences("session-a")["enabled"] is True
|
||||
assert store.deadline_preferences("session-a")["reminder_days"] == 7
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -344,7 +344,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||
assert "const todayWork = createTodayWork({" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
|
||||
assert "selectedWorkFilter === 'today' ? todayMyWork :" in html
|
||||
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html
|
||||
assert 'data-today-add' in html
|
||||
assert 'data-today-remove' in html
|
||||
assert 'data-today-move="up"' in html
|
||||
|
||||
@@ -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