Compare commits

...
Author SHA1 Message Date
timmy fdba0554df feat: prioritize the mobile Work action (Closes #755)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:59:36 +00:00
timmy d2a85cd5ea Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 16:27:32 +00:00
timmy 1f034a6f64 feat: make Updates discovery atomic (Closes #753)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:25:03 +00:00
timmy de6d6d0e61 Merge pull request 'Mute future updates during mobile triage' (#752) from timmy/751-mute-future-updates into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:57:37 +00:00
timmy 8dc3880167 feat: mute future updates during triage (Closes #751)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:55:02 +00:00
timmy b93e63ab70 Merge pull request 'Complete unread discovery before mobile Updates triage' (#750) from timmy/749-complete-updates-discovery into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:34:15 +00:00
timmy 8c82644515 feat: complete Updates discovery before triage (Closes #749)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:28:17 +00:00
timmy 6c6cd3c71b Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 15:00:45 +00:00
timmy 4f6cebae7c feat: resume mobile Updates triage (Closes #747)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:58:19 +00:00
timmy 341a3283ea Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
2026-08-13 14:28:33 +00:00
timmy 9c276fa251 feat: make live data status actionable (Closes #745)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:26:09 +00:00
timmy 940f7c05d2 Merge pull request 'Make the mobile Queues summary truthful' (#744) from timmy/743-truthful-mobile-queue-summary into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 13:55:28 +00:00
timmy d34c082da2 fix: make mobile queue summary truthful (Closes #743)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:53:04 +00:00
timmy 1c086be09b Merge pull request 'Preserve shared screenshots through operator sign-in' (#742) from timmy/741-preserve-shared-screenshot-signin into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 13:27:39 +00:00
timmy 4cfcb92eec fix: preserve shared screenshots through sign-in (Closes #741)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:25:26 +00:00
timmy b484bbef03 Merge pull request 'Share screenshots directly into mobile capture' (#740)
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Closes #739
2026-08-13 12:58:07 +00:00
timmy 623817eb7d feat: share screenshots into mobile capture (Closes #739)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:55:47 +00:00
timmy 11347e5fe0 Merge pull request 'Launch installed mobile work from task-specific app shortcuts' (#738) from timmy/737-pwa-task-shortcuts into main
CI / lint (push) Successful in 1m35s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 12:39:20 +00:00
timmy bb1712279c feat: launch mobile work from PWA shortcuts (Closes #737)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 12:26:27 +00:00
timmy e7d5196987 Merge pull request 'Wait for complete Agenda discovery before starting a session' (#736)
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
Closes #735
2026-08-13 11:58:30 +00:00
timmy 64ff5127d8 fix: wait for complete Agenda discovery (Closes #735)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:56:23 +00:00
timmy 4ac5a0f499 Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
2026-08-13 11:29:15 +00:00
timmy 715df380e0 feat: resume mobile Agenda sessions (Closes #733)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 11:27:13 +00:00
timmy 1fc70f6890 Merge pull request 'Run the mobile Agenda as a sequential work session' (#732)
CI / lint (push) Successful in 1m24s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
Closes #731
2026-08-13 10:57:28 +00:00
timmy 523968f5a3 feat: run Agenda as a work session (Closes #731)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:54:58 +00:00
timmy af27bb06e0 Merge pull request 'Bound assigned-issue reminder snapshots' (#730) from timmy/729-bounded-assigned-snapshot into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 10:26:37 +00:00
timmy 4410af09a6 Bound assigned-issue reminder snapshots
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 10:24:23 +00:00
timmy 880f60831a Merge pull request 'Configure the deadline reminder horizon per device' (#728) from timmy/727-configurable-deadline-horizon into main
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-13 09:57:25 +00:00
timmy e860d16d29 feat: configure deadline reminder horizon (Closes #727)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:55:09 +00:00
timmy abf8e23685 Merge pull request 'Bulk-authorize push devices before notification fan-out' (#726) from timmy/725-bulk-push-authorization into main
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-13 09:31:45 +00:00
49 changed files with 2221 additions and 148 deletions

No files matched your search

+33
View File
@@ -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};
});
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &amp; 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 &amp; next</button>
<button id="mute-update-next" type="button" hidden>Mute future updates &amp; 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 &amp; 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>
+78
View File
@@ -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
View File
@@ -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;
}
+31 -3
View File
@@ -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"]}]
}
}
}
+31
View File
@@ -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};
});
+3
View File
@@ -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) {
+23 -20
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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)];
+16 -5
View File
@@ -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.';
}
+53 -1
View File
@@ -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;
+29
View File
@@ -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};
});
+39
View File
@@ -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};
});
+100
View File
@@ -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,
};
});
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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
+21 -8
View File
@@ -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
]
+1 -1
View File
@@ -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
+49
View File
@@ -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)
+143 -4
View File
@@ -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"
+1 -1
View File
@@ -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';",
)
)
+38
View File
@@ -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"),
]
+1 -1
View File
@@ -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
+101
View File
@@ -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
+38
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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():
+1 -1
View File
@@ -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
+32
View File
@@ -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
+129 -6
View File
@@ -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
View File
@@ -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 &amp; 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
+52
View File
@@ -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."
}
+28
View File
@@ -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 = []
+1 -1
View File
@@ -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
+35 -4
View File
@@ -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
+4 -1
View File
@@ -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
+75 -11
View File
@@ -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",
+54
View File
@@ -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."],
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+95
View File
@@ -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 &amp; 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
+28 -2
View File
@@ -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