Compare commits

...
Author SHA1 Message Date
timmy c4ba78c8cb feat: consolidate mobile work commands (Closes #651)
CI / lint (pull_request) Successful in 1m51s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:01:41 +00:00
timmy e35d7dea35 Merge pull request 'Undo accidental mobile update acknowledgements' (#650) from timmy/649-undo-update-acknowledgement into main
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 12:37:12 +00:00
timmy b81d695585 feat: undo accidental update acknowledgements (Closes #649)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 12:34:55 +00:00
timmy 75da76c6f5 Merge pull request 'Open Web Push digests in the mobile Updates inbox' (#648) from timmy/647-push-updates-inbox into main
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 12:04:40 +00:00
timmy f78d69fba5 fix: open push digests in Updates inbox (Closes #647)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:58:11 +00:00
timmy 8ce64d2aa7 Merge pull request 'Launch work directly from mobile queues' (#646) from timmy/645-mobile-queue-launcher into main
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-12 11:28:51 +00:00
timmy 48f75cff05 feat: launch work directly from mobile queues (Closes #645)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
timmy 58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 10:56:08 +00:00
timmy 54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
timmy a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 10:29:03 +00:00
timmy c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
timmy 5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 09:54:56 +00:00
timmy 95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
timmy c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #637
2026-08-12 09:28:47 +00:00
timmy bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
timmy 9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 08:58:11 +00:00
timmy a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
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-12 08:55:52 +00:00
timmy 528d721cd3 Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 08:38:49 +00:00
timmy a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:28:03 +00:00
timmy 28a2c9faf1 Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 07:57:53 +00:00
timmy b8befc2041 fix: make Draft admission retry-safe (Closes #631)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:54:12 +00:00
timmy d23c0c894b Merge pull request 'File mobile Drafts in one sequential session' (#630) from timmy/629-sequential-draft-filing into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 07:20:10 +00:00
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
timmy 1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 06:29:46 +00:00
39 changed files with 1292 additions and 98 deletions

No files matched your search

+6 -1
View File
@@ -271,7 +271,12 @@ session linked to the credential is revoked atomically; unrelated credentials an
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in.
normally keeps the passkey available for the next sign-in. Passkey assertion counters
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
fail before a session or authorization grant is issued, while counterless authenticators
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
+19 -2
View File
@@ -10,6 +10,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-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); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -134,6 +137,15 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
.plan-today-available span { display:flex; align-items:center; gap:6px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
@@ -211,6 +223,10 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
@@ -544,8 +560,7 @@ textarea { resize: vertical; min-height: 120px; }
.my-work { margin:0; }
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.my-work-actions { display:none; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
@@ -580,11 +595,13 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.review-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
.composer-keyboard-active .create-issue-form,
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
+153 -39
View File
@@ -9,6 +9,7 @@
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
],
});
@@ -36,7 +37,7 @@
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Could not persist panel state', e);
console.warn('Panel state save failed', e);
}
});
});
@@ -55,7 +56,7 @@
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
function openMobileQueue(name) {
function selectMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
timerView.render();
@@ -74,6 +75,12 @@
planToday: () => openPlanToday(mobileTaskButtons.work),
openFallback: openMobileWorkFallback,
});
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
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; },
});
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
@@ -88,7 +95,7 @@
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
onSelectQueue: openMobileQueue,
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name),
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
@@ -122,7 +129,7 @@
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
console.warn('Could not restore My Work filter', e);
console.warn('Filter restore failed', e);
}
let lastMyWork = [];
let lastDrafts = [];
@@ -407,7 +414,13 @@
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
let resumedUnfiledCaptureId = '';
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@@ -773,6 +786,15 @@
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
});
const notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
@@ -914,6 +936,7 @@
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
},
onQueue: openWorkQueueRoute,
onClose: () => {
if (selectedIssue) closeIssueSheet(false);
if (selectedPull) closePullSheet(false);
@@ -936,7 +959,7 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
@@ -1388,6 +1411,24 @@
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
qs('#plan-today-available').value = state.capacity_minutes || '';
const build = state.build;
qs('#plan-today-build-status').textContent = build ?
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
const estimateSection = qs('#plan-today-estimates');
estimateSection.hidden = !build?.needs_estimate.length;
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
const item = planToday.item(id);
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
return '<div class="plan-today-estimate-row"><div><span class="small">' + key + '</span><strong class="my-work-card-title">' +
escapeHtml(item?.title || 'Untitled work') + '</strong></div><label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-plan-missing-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item?.title || key) + ' in minutes" /> min</label></div>';
}).join('');
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
'<div class="small plan-today-skipped-item"><strong>' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
'</strong> · ' + escapeHtml(entry.reason) + '</div>'
).join('');
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : '<p class="muted">No work selected yet.</p>';
@@ -1423,6 +1464,17 @@
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
const id = input.dataset.planMissingEstimate;
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
input.focus();
return;
}
qs('#plan-today-error').textContent = '';
renderPlanToday();
document.querySelector('[data-plan-missing-estimate]')?.focus();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
@@ -1431,6 +1483,9 @@
}
function closePlanToday(navigate = true) {
planTodayReadiness.cancel();
qs('#build-today-plan').disabled = false;
qs('#plan-today-build-status').textContent = '';
if (navigate) {
taskOverlayHistory.close();
return;
@@ -1479,6 +1534,34 @@
},
});
const planTodayReadiness = createPlanTodayReadiness({
concurrency:3,
identity:item => todayWork.identity(item),
inspect:async item => {
if (item.kind !== 'issue') return { status:'ready' };
const detail = await inspectTodayDependencies(item);
if (!detail.available) return { status:'unverified' };
if (detail.dependencies.length) return {
status:'blocked', reason:detail.dependencies.length + ' open ' +
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
};
return { status:'ready' };
},
});
async function buildTodayPlan() {
const button = qs('#build-today-plan');
const state = planToday.snapshot();
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
button.disabled = true;
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
const checks = await planTodayReadiness.run(candidates);
if (!checks) return;
planToday.buildRecommendation(checks);
button.disabled = false;
renderPlanToday();
}
function canPreviewPlanItem(item) {
if (!item || !offlineWorkMode) return Boolean(item);
const login = planningOwnerLogin || confirmedOwnerLogin ||
@@ -2118,12 +2201,8 @@
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
resumedUnfiledCaptureId = item.capture_id;
await openCreateIssueSheet();
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
dFS.start(item.capture_id);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
@@ -3344,6 +3423,7 @@
}
function closeInlineComposer() {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
@@ -3477,6 +3557,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
@@ -3613,7 +3694,6 @@
} else {
setEventStreamStatus('Update failed · showing last activity');
}
// Compatibility fallback for snapshots produced before section metadata.
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
setEventStreamStatus('Refresh failed · showing last known data' +
@@ -4053,6 +4133,7 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
@@ -4073,9 +4154,9 @@
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (resumedUnfiledCaptureId) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
@@ -4217,14 +4298,20 @@
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const queued = admission.item;
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
resumedUnfiledCaptureId = '';
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
@@ -4955,6 +5042,9 @@
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
@@ -4967,7 +5057,8 @@
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
await notificationReader.markReadAndNext(lastMyWork);
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
} finally {
qs('#mark-update-read-next').disabled = false;
}
@@ -4977,11 +5068,17 @@
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
if (result) notificationUndo.offer(result.item, result.items);
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
@@ -5348,6 +5445,7 @@
qs('#plan-today-error').textContent = '';
renderPlanToday();
});
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
function commitPlanToday(start, button) {
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
if (result === 'saved') {
@@ -5442,25 +5540,41 @@
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
selectedWorkFilter = button.dataset.workFilter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Could not persist My Work filter', 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();
updateWorkPaginationControls();
selectWorkQueue(button.dataset.workFilter);
});
});
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Filter save failed', 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();
updateWorkPaginationControls();
if (!preserveRoute && filter === 'update' && window.location.hash !== '#/my-work/updates') {
window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates');
} else if (!preserveRoute && filter !== 'update' && window.location.hash === '#/my-work/updates') {
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}
return true;
}
function openWorkQueueRoute(filter) {
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
function openDeliveryReceiptRoute() {
if (window.location.hash !== '#/my-work/drafts') return;
qs('[data-work-filter="draft"]').click();
@@ -5472,7 +5586,7 @@
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
catch (e) { console.warn('Could not persist My Work milestone lane', e); }
catch (e) { console.warn('Milestone save failed', e); }
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
+92
View File
@@ -0,0 +1,92 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
dependencies.setFilingMode(true);
session.render();
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
+27
View File
@@ -254,6 +254,14 @@
<label class="plan-today-available" for="plan-today-available">Available today
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
</label>
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
<div id="plan-today-estimate-list"></div>
</section>
<div class="plan-today-skipped" id="plan-today-skipped"></div>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
<section aria-labelledby="today-plan-heading">
@@ -550,6 +558,14 @@
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
<strong id="draft-filing-progress"></strong>
<span class="small" id="draft-filing-remaining"></span>
<div class="draft-filing-session-actions">
<button id="previous-draft" type="button">Previous</button>
<button id="skip-draft" type="button">Skip</button>
</div>
</aside>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
@@ -672,6 +688,11 @@
</section>
</div>
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
<span id="notification-undo-status"></span>
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@@ -845,8 +866,10 @@
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</section>
</dialog>
@@ -870,6 +893,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.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>
<script src="static/background-issue-sync.js"></script>
@@ -881,6 +905,7 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
@@ -890,6 +915,7 @@
<script src="static/comment-next.js"></script>
<script src="static/update-reply-read-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
@@ -915,6 +941,7 @@
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
+15
View File
@@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
@@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
function enqueue(draft, mirror = true) {
const items = read();
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) return { ...existing };
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
@@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
async function enqueueDurably(draft) {
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
}
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item: { ...existing }, background: true, durability: 'background', reused: true };
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
+25
View File
@@ -0,0 +1,25 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueueLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
const emptyMessages = {
attention: 'No items need attention.',
update: 'No unread updates are ready to open.',
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
function open(name) {
if (name === 'today') return options.openToday();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
options.announce(emptyMessages[name] || 'No work is ready to open.');
return 'empty';
}
action.click();
return 'opened';
}
return { open };
});
+9 -7
View File
@@ -34,7 +34,7 @@
}
function closeQueues() {
if (options.queueSheet?.open) options.queueSheet.close();
options.queueSheet?.open && options.queueSheet.close();
}
function start() {
@@ -42,8 +42,7 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
if (name === 'queues' && options.queueSheet) openQueues();
else options.actions[name]();
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
});
});
if (options.queueSheet) {
@@ -56,7 +55,7 @@
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name);
options.onSelectQueue?.(name, row);
});
});
}
@@ -95,17 +94,20 @@
}
function updateQueues(counts) {
const names = ['today', 'attention', 'later', 'draft'];
const names = 'today attention update later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = names.reduce((sum, name) => sum + normalized[name], 0);
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
}
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
+1 -1
View File
@@ -362,7 +362,7 @@ function createNotificationReader({
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {
+85
View File
@@ -0,0 +1,85 @@
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
let offered = null;
let pending = false;
return {
offer(item, items) {
if (!item || !Number.isInteger(item.notification_id)) return false;
offered = { item, items: Array.isArray(items) ? [...items] : [] };
const label = item.key || 'Update';
onStatus(label + ' marked read. Undo?');
onOffer(item);
return true;
},
async run() {
if (!offered || pending) return false;
const current = offered;
const label = current.item.key || 'Update';
pending = true;
onStatus('Restoring ' + label + '…');
try {
await restore(current.item.notification_id);
if (offered !== current) return false;
const restored = [current.item].concat(current.items.filter(
item => item?.notification_id !== current.item.notification_id
));
offered = null;
onItems(restored, current.item);
onStatus(label + ' is unread again.');
onClear();
return true;
} catch (_error) {
onStatus('Could not restore ' + label + '. Retry Undo.');
return false;
} finally {
pending = false;
}
},
clear() {
offered = null;
onClear();
},
};
}
async function requestNotificationUnread(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
return payload;
}
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
let timer = null;
const controller = createNotificationUndo({
restore,
onItems: (items, restored) => {
setItems(items);
const notifications = getNotifications();
if (!notifications.some(item => item.id === restored.notification_id)) {
setNotifications([{ id:restored.notification_id }].concat(notifications));
}
refresh();
},
onStatus: message => {
select('#notification-undo-status').textContent = message;
select('#my-work-action-status').textContent = message;
},
onOffer: item => {
clearTimeout(timer);
select('#notification-undo').hidden = false;
select('#undo-notification').disabled = false;
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
timer = setTimeout(() => controller.clear(), 10000);
},
onClear: () => { select('#notification-undo').hidden = true; },
});
return controller;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createNotificationUndo;
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
}
+41
View File
@@ -0,0 +1,41 @@
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
let generation = 0;
async function run(items) {
const runGeneration = ++generation;
const candidates = Array.from(items || []);
const results = new Array(candidates.length);
let next = 0;
async function worker() {
while (next < candidates.length) {
const index = next;
next += 1;
const item = candidates[index];
let result;
try {
result = await inspect(item);
} catch (_error) {
result = { status:'unverified' };
}
results[index] = [identity(item), result];
}
}
await Promise.all(Array.from(
{ length:Math.min(workerCount, candidates.length) },
() => worker(),
));
if (runGeneration !== generation) return null;
return Object.fromEntries(results);
}
function cancel() {
generation += 1;
}
return { run, cancel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
+48 -4
View File
@@ -7,6 +7,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -33,6 +35,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
@@ -74,6 +78,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -88,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
@@ -104,6 +112,41 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
const needsEstimate = [];
let used = 0;
for (const [id, item] of itemsById) {
if (selected.length >= limit) break;
const check = readiness[id] || { status:'unverified' };
if (check.status !== 'ready') {
skipped.push({
id,
status:check.status === 'blocked' ? 'blocked' : 'unverified',
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
});
continue;
}
const minutes = estimates[id];
if (!Number.isInteger(minutes)) {
needsEstimate.push(id);
continue;
}
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
selected.push(id);
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
used += minutes;
}
draftIds = selected;
capacityAware = true;
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
return buildState;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
@@ -138,6 +181,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
@@ -149,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+4
View File
@@ -128,6 +128,7 @@
}
const labels = {
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
pull_review_approved: 'Pull request approved',
@@ -141,6 +142,9 @@
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [event.device_label, event.method, event.target,
event.kind === 'passkey_counter_anomaly'
? 'Remove and re-enroll this passkey if the alert repeats'
: null,
event.status === 'pending' ? 'Outcome confirmation pending' : null]
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
+5 -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-v93';
const CACHE = 'stackchain-dashboard-shell-v96';
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/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
@@ -31,6 +32,7 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
@@ -40,6 +42,7 @@ const SHELL = [
BASE + 'static/comment-next.js',
BASE + 'static/update-reply-read-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
@@ -65,6 +68,7 @@ const SHELL = [
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
+15
View File
@@ -15,6 +15,9 @@
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
if (parts[2] === 'updates' && parts.length === 3) {
return { kind: 'queue', filter: 'update' };
}
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
@@ -38,6 +41,7 @@
}
function sameRoute(item, route) {
if (route.kind === 'queue') return false;
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
@@ -48,6 +52,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -100,6 +105,16 @@
active = '';
return;
}
if (route.kind === 'queue') {
if (!ready) return;
resolution += 1;
resolving = '';
if (active && active !== fragment) onClose();
if (active === fragment) return;
active = fragment;
onQueue(route.filter);
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
+3 -2
View File
@@ -28,8 +28,9 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
),
}
CACHE_DECLARATION = re.compile(
+8
View File
@@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
async def mark_notification_unread(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
headers=_auth(),
)
response.raise_for_status()
async def acknowledge_notification(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
+43 -2
View File
@@ -1595,9 +1595,19 @@ async def verify_passkey_authorization(
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
store.advance_counter,
stored.credential_id,
expected=stored.sign_count,
new=verified.new_sign_count,
)
if not updated:
await asyncio.to_thread(
_security_event_store().record,
"passkey_counter_anomaly",
method="passkey",
device_label=stored.device_label,
target=f"{payload.action}:{payload.target}",
)
raise ValueError("stale passkey counter")
grant = await dashboard_auth.issue_step_up(
request.state.dashboard_session,
@@ -1686,9 +1696,19 @@ async def verify_passkey_authentication(
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
store.advance_counter,
stored.credential_id,
expected=stored.sign_count,
new=verified.new_sign_count,
)
if not updated:
await asyncio.to_thread(
_security_event_store().record,
"passkey_counter_anomaly",
method="passkey",
device_label=stored.device_label,
target="sign_in:dashboard",
)
raise ValueError("stale passkey counter")
await dashboard_auth.revoke_managed_session(stored.management_id)
signed, session = await asyncio.to_thread(
@@ -3432,6 +3452,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/unread")
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mark_notification_unread(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Restoring the update timed out. Retry Undo."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update could not be restored. Retry Undo."},
status_code=503,
)
return JSONResponse({"id": thread_id, "status": "unread"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
+17 -4
View File
@@ -225,13 +225,26 @@ class PasskeyStore:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
def advance_counter(
self, credential_id: bytes, *, expected: int, new: int
) -> bool:
if expected == 0 and new == 0:
comparison = "sign_count = 0"
elif new > expected:
comparison = "sign_count = ?"
else:
return False
try:
with self._connect() as connection:
parameters = (
(new, credential_id)
if expected == 0 and new == 0
else (new, credential_id, expected)
)
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
f"UPDATE passkey_credentials SET sign_count = ? "
f"WHERE credential_id = ? AND {comparison}",
parameters,
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
+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-v93" in worker
assert "stackchain-dashboard-shell-v96" in worker
+96
View File
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
options = await client.post("/api/v1/passkeys/authentication/options")
denied = await client.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
events = main._security_event_store().list(limit=10).events
assert denied.status_code == 401
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
assert events[0].device_label == "Phone"
assert events[0].target == "sign_in:dashboard"
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
options = await client.post(
"/api/v1/passkeys/authorization/options",
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
headers=headers,
)
denied = await client.post(
"/api/v1/passkeys/authorization/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"action": "merge_pull",
"target": "stackchain/dashboard#42",
},
headers=headers,
)
events = main._security_event_store().list(limit=10).events
assert options.status_code == 200
assert denied.status_code == 401
assert "grant" not in denied.json()
assert events[0].kind == "passkey_counter_anomaly"
assert events[0].target == "merge_pull:stackchain/dashboard#42"
@pytest.mark.anyio
async def test_passkey_options_are_source_limited_before_challenge_generation(
access_control, monkeypatch
+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-v93';",
"const CACHE = 'stackchain-dashboard-shell-v96';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+52
View File
@@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence),
}});
const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}}));
"""
output = run_node(script)
assert output["sequence"] == 1
assert len(output["items"]) == 1
assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1"
assert output["items"][0]["operationId"] == "draft-op-1"
assert output["items"][0]["sourceCaptureId"] == "capture-7"
def test_durable_source_draft_retry_reconciles_without_allocating_another_operation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0; const reconciliations = []; let syncRequests = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence),
backgroundSync:{{
reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)),
requestSync:async()=>{{ syncRequests += 1; }},
}},
}});
(async()=>{{
const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}};
const first=await outbox.enqueueDurably(draft);
const retried=await outbox.enqueueDurably(draft);
process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert "reused" not in output["first"]
assert output["retried"]["reused"] is True
assert output["reconciliations"] == [["durable-1"], ["durable-1"]]
assert output["syncRequests"] == 2
assert len(output["items"]) == 1
+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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/later-sync.js'" in source
+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-v93" in worker
assert "stackchain-dashboard-shell-v96" in worker
+5 -1
View File
@@ -20,6 +20,7 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
("#review-sheet .review-sheet-panel", "#review-inline-composer", "#review-inline-body", "#save-inline-comment", "#review-submit-status"),
]:
for selector in (panel, workspace, composer, submit, status):
assert repr(selector) in dashboard
@@ -27,10 +28,13 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
assert "workspace:qs('#create-issue-form')" in dashboard
assert "focusWithin:true" in dashboard
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
assert "mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'))" in dashboard
assert "height:var(--composer-viewport-height,100dvh)" in css
assert "top:var(--composer-viewport-top,0px)" in css
assert ".composer-keyboard-active" in css
assert ".create-issue-panel.composer-keyboard-active" in css
assert ".review-sheet-panel.composer-keyboard-active" in css
assert ".composer-keyboard-active .review-inline-composer" in css
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
@@ -41,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-v93" in worker
assert "stackchain-dashboard-shell-v96" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -186,7 +186,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-v93" in worker
assert "stackchain-dashboard-shell-v96" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -69,4 +69,4 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert "header { min-height:56px; max-height:64px;" in html
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
assert ".my-work-actions button { min-height:44px;" in html
assert ".my-work-actions { display:none; }" in html
+55 -10
View File
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
@@ -292,26 +293,30 @@ sheet.showModal = function () {{ this.open = true; }};
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
const dock = createDock({{
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
onSelectQueue:name=>selected.push(name),
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.later.click();
rows.update.click();
const closedAfterSelect = !sheet.open;
queues.click(); rows.recaps.click();
const closedAfterUtility = !sheet.open;
queues.click(); close.click();
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, selected,
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
updateLabel:rows.update.attributes['aria-label'],
queueFocuses:queues.focuses,
columnState:nav.attributes['data-attention'] || null,
}}));
@@ -322,15 +327,47 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"opened": True,
"closedAfterSelect": True,
"selected": ["later"],
"closedAfterUtility": True,
"selected": ["update"],
"utilities": ["recaps:trigger"],
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
"badge": "10",
"badgeLabel": "Queues, 10 items",
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
"queueFocuses": 2,
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
"updateLabel": "Updates, 5 unread conversations",
"queueFocuses": 3,
"columnState": None,
}
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
selectFilter: name => calls.push('filter:' + name),
firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null,
announce: message => calls.push('announce:' + message),
}});
const opened = launcher.open('attention');
const empty = launcher.open('later');
process.stdout.write(JSON.stringify({{opened, empty, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": "opened",
"empty": "empty",
"calls": [
"filter:attention",
"open:first",
"filter:later",
"announce:No deferred work is ready to open.",
],
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
html = await dashboard()
@@ -340,12 +377,20 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert 'aria-labelledby="mobile-queue-heading"' in html
assert 'data-mobile-queue="today"' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
assert 'data-mobile-queue="later"' in html
assert 'data-mobile-queue="draft"' in html
assert 'data-mobile-queue="recaps"' in html
assert '<strong>Recaps</strong><small>History</small>' in html
assert 'id="mobile-queue-count"' in html
assert 'mobileTaskDock.updateQueues(counts)' in html
assert "onSelectQueue: openMobileQueue" in html
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "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')" in html
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
assert '.mobile-queue-sheet' in html
assert '.my-work-actions { display:none;' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
assert '.mobile-task-dock[data-attention="true"]' not in html
@@ -389,7 +434,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "onSelectQueue: openMobileQueue" in html
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
+103 -1
View File
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
@@ -395,6 +396,49 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/updates'}};
const calls = [];
const stack = ['#/my-work/updates'];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: queue => calls.push(['queue', queue]),
onOpen: item => calls.push(['open', item.notification_id]),
onClose: () => calls.push(['close']),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([]);
controller.open({{kind:'update', notification_id:42}});
controller.close();
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["queue", "update"],
["open", 42],
["close"],
["queue", "update"],
],
"hash": "#/my-work/updates",
"parsed": {"kind": "queue", "filter": "update"},
}
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -507,6 +551,10 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
assert "onQueue: openWorkQueueRoute" in html
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
assert "filter === 'update' && window.location.hash !== '#/my-work/updates'" in html
assert "window.history.pushState({ workQueue:'update' }, '', '#/my-work/updates')" in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -4076,6 +4124,58 @@ const reader = buildMyWork.createNotificationReader({{
assert output["results"][1] is False
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
script = f"""
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
const states = [];
const statuses = [];
const calls = [];
let attempts = 0;
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
const undo = createNotificationUndo({{
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
onItems: items => states.push(items.map(value => value.notification_id)),
onStatus: status => statuses.push(status),
}});
(async () => {{
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
const first = undo.run();
const duplicate = undo.run();
const firstResults = await Promise.all([first, duplicate]);
const retry = await undo.run();
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42, 42]
assert output["states"] == [[42, 43]]
assert output["firstResults"] == [False, False]
assert output["retry"] is True
assert output["statuses"] == [
"repo#7 marked read. Undo?",
"Restoring repo#7…",
"Could not restore repo#7. Retry Undo.",
"Restoring repo#7…",
"repo#7 is unread again.",
]
@pytest.mark.anyio
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
html = await dashboard()
assert 'id="notification-undo"' in html
assert 'id="undo-notification" type="button"' in html
assert "requestNotificationUnread" in html
assert "createDashboardNotificationUndo({" in html
assert ".notification-undo { position:fixed;" in html
assert "env(safe-area-inset-bottom)" in html
assert ".notification-undo button { min-height:44px;" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
@@ -4354,7 +4454,9 @@ reader.open(item, [item]).then(() => {{
)
output = json.loads(result.stdout)
assert output["result"] == {"items": [], "next": None}
assert output["result"] == {"items": [], "item": {
"kind": "update", "notification_id": 42, "has_update": True,
}, "next": None}
assert output["events"] == [
["status", "Marking update read…"],
["items", []],
+43
View File
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_unread(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
@@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
restored = []
async def mark_unread(thread_id):
restored.append(thread_id)
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/unread")
invalid = await client.patch("/api/v1/notifications/0/unread")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "unread"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert restored == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
+41
View File
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
).fetchall()
assert "source_hash" in columns
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
database = tmp_path / "passkeys.sqlite3"
first = PasskeyStore(database, clock=lambda: 1_000.0)
second = PasskeyStore(database, clock=lambda: 1_000.0)
first.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
assert first.get(b"phone-credential").sign_count == 5
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
tmp_path,
):
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
store.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
store.register(
credential_id=b"counterless-credential",
public_key=b"counterless-public-key",
sign_count=0,
device_label="Security key",
management_id="counterless-management-id",
)
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
+79 -1
View File
@@ -164,6 +164,77 @@ process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}
}
def test_plan_today_builds_ranked_ready_capacity_fit_without_saving():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = (number, reason) => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, reason}});
const saved = [];
+const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: plan => saved.push(plan),
}});
const id = n => 'issue:stackchain/dashboard:' + n + ':';
planner.open([], [item(1, 'Due today'), item(2, 'Unread update'), item(3, 'Assigned to you'), item(4, 'Needs your review')], {{
capacity_minutes:90, estimates:{{[id(1)]:60,[id(2)]:45,[id(3)]:30,[id(4)]:20}}
}});
const build = planner.buildRecommendation({{
[id(1)]:{{status:'ready'}},
[id(2)]:{{status:'blocked', reason:'Waiting on API'}},
[id(3)]:{{status:'ready'}},
[id(4)]:{{status:'unverified'}},
}});
process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}}));
""".replace("+const", "const")
result = run_node(script)
assert result["build"] == {
"selected": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"],
"reasons": {
"issue:stackchain/dashboard:1:": "Due today",
"issue:stackchain/dashboard:3:": "Assigned to you",
},
"skipped": [
{"id": "issue:stackchain/dashboard:2:", "status": "blocked", "reason": "Waiting on API"},
{"id": "issue:stackchain/dashboard:4:", "status": "unverified", "reason": "Could not verify readiness"},
],
"needs_estimate": [],
}
assert result["snapshot"]["ids"] == [
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"
]
assert result["snapshot"]["planned_minutes"] == 90
assert result["snapshot"]["remaining_minutes"] == 0
assert result["snapshot"]["build"] == result["build"]
assert result["saved"] == []
def test_plan_today_estimates_ready_omission_and_refits_without_another_readiness_check():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
const id = n => 'issue:stackchain/dashboard:' + n + ':';
planner.open([], [item(1), item(2)], {{capacity_minutes:60, estimates:{{[id(1)]:30}}}});
planner.buildRecommendation({{[id(1)]:{{status:'ready'}}, [id(2)]:{{status:'ready'}}}});
const before = planner.snapshot();
const estimated = planner.setEstimate(id(2), 30);
const after = planner.snapshot();
process.stdout.write(JSON.stringify({{before, estimated, after}}));
"""
result = run_node(script)
assert result["before"]["ids"] == ["issue:stackchain/dashboard:1:"]
assert result["before"]["build"]["needs_estimate"] == ["issue:stackchain/dashboard:2:"]
assert result["estimated"] is True
assert result["after"]["ids"] == [
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"
]
assert result["after"]["estimates"]["issue:stackchain/dashboard:2:"] == 30
assert result["after"]["remaining_minutes"] == 0
assert result["after"]["build"]["needs_estimate"] == []
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -278,6 +349,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
assert 'id="plan-today-available"' in html
assert 'id="build-today-plan"' in html
assert 'id="plan-today-build-status" role="status" aria-live="polite"' in html
assert 'id="plan-today-estimates"' in html
assert 'Estimate to finish your plan' in html
assert 'data-plan-missing-estimate="' in html
assert '.plan-today-estimates input { min-height:44px;' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
@@ -333,6 +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-v93" in source
assert "stackchain-dashboard-shell-v96" 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
+94
View File
@@ -0,0 +1,94 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
READINESS = Path(__file__).parents[1] / "frontend" / "plan-today-readiness.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_plan_readiness_bounds_concurrency_and_preserves_ranked_order():
script = f"""
const createReadiness = require({json.dumps(str(READINESS))});
let active = 0;
let peak = 0;
const completed = [];
const delays = {{1:40, 2:5, 3:20, 4:1, 5:10, 6:2}};
const readiness = createReadiness({{
concurrency:3,
identity:item => 'issue:' + item.number,
inspect:async item => {{
active += 1;
peak = Math.max(peak, active);
await new Promise(resolve => setTimeout(resolve, delays[item.number]));
completed.push(item.number);
active -= 1;
return item.number === 4 ? {{status:'blocked', reason:'Waiting'}} : {{status:'ready'}};
}},
}});
readiness.run([1,2,3,4,5,6].map(number => ({{kind:'issue', number}}))).then(result => {{
process.stdout.write(JSON.stringify({{peak, completed, entries:Object.entries(result)}}));
}});
"""
result = run_node(script)
assert result["peak"] == 3
assert result["completed"] != [1, 2, 3, 4, 5, 6]
assert result["entries"] == [
["issue:1", {"status": "ready"}],
["issue:2", {"status": "ready"}],
["issue:3", {"status": "ready"}],
["issue:4", {"status": "blocked", "reason": "Waiting"}],
["issue:5", {"status": "ready"}],
["issue:6", {"status": "ready"}],
]
def test_plan_readiness_supersedes_stale_builds_and_normalizes_real_failures():
script = f"""
const createReadiness = require({json.dumps(str(READINESS))});
let releaseFirst;
const firstGate = new Promise(resolve => {{ releaseFirst = resolve; }});
const readiness = createReadiness({{
concurrency:2,
identity:item => 'issue:' + item.number,
inspect:async item => {{
if (item.number === 1) await firstGate;
if (item.number === 3) throw new Error('upstream details');
return {{status:'ready'}};
}},
}});
const stale = readiness.run([{{number:1}}, {{number:2}}]);
const current = readiness.run([{{number:3}}, {{number:4}}]);
current.then(currentResult => {{
releaseFirst();
stale.then(staleResult => process.stdout.write(JSON.stringify({{staleResult, currentResult}})));
}});
"""
assert run_node(script) == {
"staleResult": None,
"currentResult": {
"issue:3": {"status": "unverified"},
"issue:4": {"status": "ready"},
},
}
@pytest.mark.anyio
async def test_dashboard_uses_bounded_supersedable_readiness_for_build_my_today():
html = await dashboard()
assert '<script src="static/plan-today-readiness.js"></script>' in html
assert "const planTodayReadiness = createPlanTodayReadiness({" in html
assert "concurrency:3" in html
assert "const checks = await planTodayReadiness.run(candidates);" in html
assert "if (!checks) return;" in html
assert "planTodayReadiness.cancel();" in html
assert "Promise.all(candidates.map" not in html
+39
View File
@@ -47,3 +47,42 @@ const boundary={{
"hidden": False,
"focused": True,
}
def test_counter_anomaly_activity_gives_passkey_remediation_guidance():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const element=()=>({{
hidden:true, disabled:false, textContent:'', children:[], className:'',
addEventListener(){{}}, focus(){{}}, replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},
}});
const ids={{
'active-devices':element(), 'active-devices-sheet':element(),
'active-devices-list':element(), 'active-devices-status':element(),
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
'enroll-passkey':element(), 'security-activity-list':element(),
'security-activity-status':element(), 'load-more-security-activity':element(),
'close-active-devices':element(),
}};
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
const boundary={{
listActiveDevices:async()=>[], listPasskeys:async()=>[],
listSecurityEvents:async()=>({{events:[{{
kind:'passkey_counter_anomaly', device_label:'Phone', method:'passkey',
target:'sign_in:dashboard', status:'completed', created_at:1,
}}],authentication_alerts:[],next_cursor:null}}),
}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
const row=ids['security-activity-list'].children[0];
console.log(JSON.stringify(row.children.map(child=>child.textContent)));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
title, detail = json.loads(completed.stdout)
assert title == "Passkey counter anomaly"
assert "Remove and re-enroll this passkey" in detail
+14 -10
View File
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v96" 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 +154,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-v93" in source
assert "stackchain-dashboard-shell-v96" 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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +169,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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +177,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-v93" in source
assert "stackchain-dashboard-shell-v96" 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-v93" in source
assert "stackchain-dashboard-shell-v96" 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,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v96" 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-v93" in source
assert "stackchain-dashboard-shell-v96" 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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -654,7 +654,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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -684,6 +684,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/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
@@ -694,6 +695,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
@@ -703,6 +705,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/comment-next.js",
"/dashboard/static/update-reply-read-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
@@ -728,6 +731,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
+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-v93';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v96';" 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-v93" in source
assert "stackchain-dashboard-shell-v96" in source
assert "BASE + 'static/today-sync.js'" in source
+47 -4
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
def run_node(script: str):
@@ -15,6 +16,28 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
script = f"""
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
const session = createDraftFilingSession({{list:()=>captures}});
const opened = session.start('middle');
const skipped = session.next();
captures = [{{id:'newest'}}];
const afterFiled = session.removeCurrentAndNext('oldest');
const previous = session.previous();
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
"""
output = run_node(script)
assert output == {
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
}
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -335,13 +358,14 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
@@ -349,6 +373,25 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
assert '<script src="static/draft-filing-session.js"></script>' in html
assert 'id="draft-filing-session"' in html
assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
html = await dashboard()