Compare commits

...
Author SHA1 Message Date
timmy 679639394f feat: compress dashboard shell over HTTP (#417)
CI / lint (pull_request) Successful in 47s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 18:33:40 +00:00
rockachopa 3b374cbb50 Merge pull request 'Queue available work in Today without interrupting the current session' (#416)
CI / lint (push) Successful in 49s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #415
2026-08-09 18:00:05 +00:00
timmy ad5e50eb48 feat: queue available work in Today (#415)
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:57:48 +00:00
rockachopa 4b8d0c4504 Merge pull request 'Assign and start work from global search' (#414) from timmy/413-search-assign-and-start into main
CI / lint (push) Successful in 45s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 17:08:02 +00:00
timmy 6e3b6804c1 feat: assign and start work from search (#413)
CI / lint (pull_request) Successful in 48s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 17:06:16 +00:00
rockachopa c31129f1cc Merge pull request 'Assign available work and start it from Find' (#412) from timmy/411-assign-and-start into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 16:22:55 +00:00
timmy 4afe4dd3b9 feat: assign and start available work (#411)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 16:21:20 +00:00
rockachopa 985472eb06 Merge pull request 'Continue Today from the mobile Work dock' (#410) from timmy/409-continue-today-mobile-dock into main
CI / lint (push) Successful in 47s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-09 15:44:22 +00:00
timmy 8b36455c21 feat: continue Today from mobile Work dock (#409)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 15:42:42 +00:00
rockachopa 3ba1fe76f0 Merge pull request 'Advance Today after merging a planned pull request' (#408) from timmy/407-merge-pr-and-advance-today into main
CI / lint (push) Successful in 41s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
Advance Today after merging a planned pull request (#408)

Closes #407
2026-08-09 14:56:39 +00:00
timmy 321474e8cf feat: advance Today after pull request merge (#407)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:55:03 +00:00
rockachopa 166d14dd35 Merge pull request 'Preview work before adding it to Today' (#406) from timmy/405-plan-today-preview into main
CI / lint (push) Successful in 44s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 14:24:29 +00:00
timmy 7a62f7ead7 feat: preview work before planning (#405)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 14:22:37 +00:00
rockachopa 461f90c8ff Merge pull request 'Support issue capture across all accessible repositories' (#404) from timmy/403-issue-capture-repository-pagination into main
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 13:40:58 +00:00
timmy 9ea590a65c feat: paginate issue capture repositories (#403)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 13:39:27 +00:00
rockachopa 33a6d1bb6d Merge pull request 'Make Plan Today browser-back safe on mobile' (#402) from timmy/401-plan-today-back into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 4s
2026-08-09 12:40:56 +00:00
timmy b9ae9c6c83 feat: make Plan Today browser-back safe (#401)
CI / lint (pull_request) Successful in 44s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:39:19 +00:00
rockachopa 45ceedb679 Merge pull request 'Expire offline private work with operator sessions' (#400) from timmy/399-offline-session-lease into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 12:06:15 +00:00
timmy 2e2a7821ae feat: expire offline work with sessions (#399)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 12:03:55 +00:00
rockachopa 33427d2407 Merge pull request 'Recover foreground dashboard actions from stalled requests' (#398) from timmy/397-foreground-request-deadlines into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 11:39:22 +00:00
timmy f0b0b703ff feat: bound foreground API requests (#397)
CI / lint (pull_request) Successful in 41s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:37:57 +00:00
rockachopa 055a9dbb2a Merge pull request 'Resume interrupted Today sessions' (#396) from timmy/395-resume-today-session into main
CI / lint (push) Successful in 43s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 6s
2026-08-09 11:03:45 +00:00
timmy 1f7fd093fd feat: resume interrupted Today sessions (#395)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 11:02:00 +00:00
rockachopa ff0e47cbe7 Merge pull request 'Collapse per-card planning actions on mobile' (#394) from timmy/393-mobile-card-planning-disclosure into main
CI / lint (push) Successful in 40s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s
2026-08-09 10:38:25 +00:00
timmy c2da869c47 feat: collapse mobile card planning controls (#393)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:36:41 +00:00
rockachopa 8ada75ec33 Merge pull request 'Close issues and continue the Today session' (#392) from timmy/391-close-and-next into main
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-09 10:14:29 +00:00
timmy 6dea5fd1ef feat: close issues and continue Today session (#391)
CI / lint (pull_request) Successful in 42s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-09 10:12:55 +00:00
35 changed files with 2473 additions and 107 deletions

No files matched your search

+11 -2
View File
@@ -37,7 +37,11 @@ the private content. Issue capture and authored mobile actions (issue
comments, pull-request comments, notification replies, and reviews) persist per-draft
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
another worker replays a confirmed result instead of posting duplicate content. The ordered,
five-item Today plan syncs across the operator's devices. Server revisions prevent delayed
five-item Today plan syncs across the operator's devices. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
offline operations. Planning edits can remain offline for up to 30 days. After that, the
@@ -57,7 +61,12 @@ triageable offline: **Queue read & next** writes an account-bound, notification-
deduplicated acknowledgement to the durable background delivery system, removes the
update from the local queue immediately, and opens the next saved conversation. A cold
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
authenticated notification-read endpoint and keeps transient failures queued. Installed-app
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
not changed by this session boundary. Installed-app
navigations are also deadline-bounded: after four seconds without a network response,
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
+49
View File
@@ -0,0 +1,49 @@
function createAssignAndStart({ available, claim, start, queue, recover, announce }) {
let request = null;
function run(item, { alreadyOwned = false, destination = 'start' } = {}) {
if (request) return request;
if (!available()) {
announce('Today is full—remove an item before assigning this issue.');
return Promise.resolve('full');
}
request = Promise.resolve()
.then(() => alreadyOwned ? item : claim(item))
.then(confirmed => {
const outcome = destination === 'queue' ? queue(confirmed) : start(confirmed);
if (outcome === 'queued') {
announce(alreadyOwned ? 'Queued in Today. Keep finding work when ready.' :
'Assigned and queued in Today. Keep finding work when ready.');
return outcome;
}
if (outcome === 'started') {
announce(alreadyOwned ? 'Added to Today and ready to work.' :
'Assigned, added to Today, and ready to work.');
return outcome;
}
if (destination === 'queue') {
if (outcome === 'sync-unavailable') {
announce(alreadyOwned ?
'Saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.' :
'Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.');
} else {
announce(alreadyOwned ?
'Today could not be queued. The issue is open so you can recover.' :
'Assigned to you, but Today could not be queued. The issue is open so you can recover.');
}
} else {
announce(alreadyOwned ?
'Today could not start. The issue is open so you can recover.' :
'Assigned to you, but Today could not start. The issue is open so you can recover.');
}
recover(confirmed);
return 'recovery';
})
.finally(() => { request = null; });
return request;
}
return { run };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAssignAndStart;
+39
View File
@@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createCardPlanning = factory;
})(typeof self !== 'undefined' ? self : this, function createCardPlanning(root) {
function sync(disclosure) {
disclosure.querySelector('summary')?.setAttribute(
'aria-expanded', String(disclosure.open)
);
}
function wire() {
const disclosures = Array.from(root.querySelectorAll('[data-card-planning]'));
disclosures.forEach(disclosure => {
if (disclosure.dataset.cardPlanningBound === 'true') return;
disclosure.dataset.cardPlanningBound = 'true';
sync(disclosure);
disclosure.addEventListener('toggle', () => {
if (disclosure.open) {
disclosures.forEach(other => {
if (other === disclosure || !other.open) return;
other.open = false;
sync(other);
});
}
sync(disclosure);
});
disclosure.addEventListener('keydown', event => {
if (event.key !== 'Escape' || !disclosure.open) return;
disclosure.open = false;
sync(disclosure);
disclosure.querySelector('summary')?.focus();
event.preventDefault();
event.stopPropagation();
});
});
}
return { wire };
});
+17 -1
View File
@@ -26,6 +26,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
let duplicateRequest = 0;
let duplicateState = {status: 'idle', key: '', candidates: []};
let acknowledgedDuplicateKey = '';
const repositoryPageRequests = new Map();
const safeLabelIds = value => Array.from(new Set(
(Array.isArray(value) ? value : []).filter(id => Number.isInteger(id) && id > 0)
)).slice(0, 20);
@@ -143,6 +144,21 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
);
}
function loadRepositoryPage(page) {
const safePage = Math.max(1, Math.floor(Number(page) || 1));
if (repositoryPageRequests.has(safePage)) return repositoryPageRequests.get(safePage);
const request = fetchJson('api/v1/repositories?page=' + safePage + '&limit=50')
.then(payload => ({
items: Array.isArray(payload?.items) ? payload.items : [],
page: Number(payload?.page) || safePage,
total: Math.max(0, Number(payload?.total) || 0),
has_more: payload?.has_more === true,
}))
.finally(() => repositoryPageRequests.delete(safePage));
repositoryPageRequests.set(safePage, request);
return request;
}
function duplicateKey(draft) {
const repository = String(draft?.repository || '').trim();
const title = String(draft?.title || '').replace(/\s+/g, ' ').trim();
@@ -214,7 +230,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
}
return {
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, findDuplicates,
saveDraft, loadDraft, clearDraft, loadLabels, loadMilestones, loadRepositoryPage, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
};
+19
View File
@@ -94,6 +94,10 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
.plan-today-item-actions { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
.plan-today-candidate-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:6px; }
.plan-preview-actions { position:fixed; z-index:76; right:16px; bottom:calc(16px + env(safe-area-inset-bottom)); display:grid; grid-template-columns:1fr 1fr; gap:8px; width:min(420px,calc(100vw - 32px)); padding:10px; border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); box-shadow:0 12px 36px rgba(0,0,0,.45); }
.plan-preview-actions[hidden] { display:none; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
@@ -121,6 +125,10 @@ textarea { resize: vertical; min-height: 120px; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.later-actions button { min-height:44px; width:100%; }
.today-actions button { min-height:44px; width:100%; }
.card-planning { min-width:0; }
.card-planning > summary { display:none; }
.card-planning:not([open]) > .card-planning-actions { display:grid; }
.card-planning-actions { display:grid; gap:8px; min-width:0; }
.detail-defer { grid-column:1/-1; max-width:100%; }
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -242,6 +250,7 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
@@ -254,6 +263,8 @@ textarea { resize: vertical; min-height: 120px; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
.find-work-detail { min-width:0; display:grid; gap:10px; padding:10px; border-radius:10px; background:#0b1526; }
.find-work-description { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.find-work-detail a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
@@ -264,6 +275,7 @@ textarea { resize: vertical; min-height: 120px; }
.search-preview-header button, .search-preview-actions button, .search-preview-actions a { min-height:44px; }
.search-preview-body { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
.search-preview-actions { position:sticky; bottom:0; display:grid; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.search-preview-primary-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.search-preview-actions a { display:flex; align-items:center; justify-content:center; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere; white-space:normal; }
.markdown-content > :first-child { margin-top:0; }
@@ -284,6 +296,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.create-issue-header button, .create-issue-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-form label { display:grid; gap:6px; }
.create-issue-form select, .create-issue-form input[type="date"] { min-height:44px; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:#e5e7eb; }
.create-issue-labels { display:grid; gap:8px; margin:0; padding:0; border:0; }
@@ -351,9 +364,15 @@ textarea { resize: vertical; min-height: 120px; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-card { min-width:0; overflow-x:hidden; }
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
.card-planning > summary::-webkit-details-marker { display:none; }
.card-planning:not([open]) > .card-planning-actions { display:none; }
.card-planning[open] > .card-planning-actions { display:grid; margin-top:8px; }
.plan-today-panel { width:100%; border-left:0; padding:14px; }
.plan-today-item { grid-template-columns:1fr; }
.plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr); width:100%; }
.plan-today-candidate-actions { grid-template-columns:repeat(2,1fr); width:100%; }
.plan-today-item-actions button { min-width:0; padding-inline:4px; }
.work-filters { width:100%; }
.work-filter { flex:1 1 calc(50% - 8px); }
+351 -34
View File
@@ -1,6 +1,7 @@
(function(){
const qs = (s, el=document) => el.querySelector(s);
const fmt = (d) => new Date(d).toLocaleString();
const cardPlanning = createCardPlanning(document);
const mobileComposerViewport = createMobileComposerViewport({
viewport: window.visualViewport,
mediaQuery: window.matchMedia('(max-width: 600px)'),
@@ -42,20 +43,30 @@
Array.from(document.querySelectorAll('[data-mobile-task]')).map(button => [button.dataset.mobileTask, button])
);
const mobileTaskOverlays = Array.from(document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal'));
function openMobileWork() {
function openMobileWorkFallback() {
const counts = countMyWork(activeMyWork);
const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');
const filter = counts.attention ? 'attention' : 'all';
qs('[data-work-filter="' + filter + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
const mobileWorkEntry = createMobileWorkEntry({
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
openFallback: openMobileWorkFallback,
});
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
attentionBadge: qs('#mobile-attention-count'),
overlays: mobileTaskOverlays,
actions: {
work: openMobileWork,
work: () => mobileWorkEntry.open(),
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
@@ -664,12 +675,31 @@
openRoutedWork(item, null, { replace:true });
}
const sessionCheckpoint = createWorkSessionCheckpoint({
storage: localStorage,
getLogin: () => confirmedOwnerLogin,
onError: () => {
qs('#my-work-action-status').textContent =
'Session recovery could not be saved on this device. You can keep working.';
},
});
function updateWorkSessionActions() {
const active = workSession.active();
qs('#start-work-session').hidden = active;
qs('#resume-today-session').hidden = active || !todayMyWork.length || !workSession.resumable();
qs('#end-today-session').hidden = !active;
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
}
const workSession = createWorkSession({
getItems: () => selectedWorkFilter === 'today' ? todayMyWork : activeMyWork,
getFilter: () => selectedWorkFilter === 'today' ? 'all' : selectedWorkFilter,
getMilestone: () => selectedWorkMilestone,
checkpoint: sessionCheckpoint,
checkpointEnabled: () => selectedWorkFilter === 'today',
onOpen: openWorkSessionItem,
onProgress: state => {
updateWorkSessionActions();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = false; });
document.querySelectorAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + state.index + ' of ' + state.total;
@@ -685,10 +715,36 @@
closeOpenWorkSheets();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
updateWorkSessionActions();
qs('#start-work-session').focus();
},
});
function selectTodayWork() {
qs('[data-work-filter="today"]').click();
}
function continueTodaySession() {
selectTodayWork();
if (!workSession.reopen() && !workSession.reconcile()) {
qs('#my-work-action-status').textContent = 'Current Today item is no longer available.';
updateWorkSessionActions();
}
}
function resumeTodaySession() {
selectTodayWork();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
}
function startTodaySession() {
selectTodayWork();
workSession.start();
}
const createAndStart = createCreateAndStart({
todayWork,
todaySync,
@@ -701,6 +757,49 @@
announce: message => { qs('#my-work-action-status').textContent = message; },
});
const queueToday = createQueueToday({
todayWork,
todaySync,
refresh: refreshMyWorkView,
warm: warmTodayOffline,
});
function acceptClaimedIssue(confirmed) {
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat((lastContextSnapshot.issues || []).filter(candidate =>
candidate.repository !== confirmed.repository || candidate.number !== confirmed.number
));
lastMyWork = buildMyWork(lastContextSnapshot);
return lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
}
const assignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: item => findWorkController.claim(item),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
queue: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
return queueToday(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#find-work'));
},
announce: message => {
qs('#find-work-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
let planTodayTrigger = null;
function planTodayItemMarkup(item, selected, index = -1) {
const id = todayWork.identity(item);
@@ -708,7 +807,7 @@
const title = escapeHtml(item.title || 'Untitled work');
const controls = selected ?
'<div class="plan-today-item-actions"><button type="button" data-plan-move="up" data-plan-id="' + escAttr(id) + '"' + (index === 0 ? ' disabled' : '') + '>Up</button><button type="button" data-plan-move="down" data-plan-id="' + escAttr(id) + '"' + (index === planToday.snapshot().count - 1 ? ' disabled' : '') + '>Down</button><button type="button" data-plan-remove="' + escAttr(id) + '">Remove</button></div>' :
'<button type="button" data-plan-add="' + escAttr(id) + '">Add</button>';
'<div class="plan-today-candidate-actions"><button type="button" data-plan-preview="' + escAttr(id) + '">Preview</button><button type="button" data-plan-add="' + escAttr(id) + '">Add</button></div>';
return '<article class="plan-today-item"><div class="plan-today-item-copy"><span class="small">' + key + '</span><strong class="my-work-card-title">' + title + '</strong></div>' + controls + '</article>';
}
@@ -727,6 +826,15 @@
qs('#plan-today-error').textContent = result === 'full' ? 'Today is full. Remove an item before adding another.' : '';
renderPlanToday();
}));
document.querySelectorAll('[data-plan-preview]').forEach(button => button.addEventListener('click', () => {
const item = planToday.item(button.dataset.planPreview);
if (!canPreviewPlanItem(item)) {
qs('#plan-today-error').textContent = 'Preview unavailable offline. Reconnect or add the item without previewing it.';
return;
}
qs('#plan-today-error').textContent = '';
if (planTodayPreview.open(item, button)) taskOverlayHistory.open('plan-today-preview');
}));
document.querySelectorAll('[data-plan-remove]').forEach(button => button.addEventListener('click', () => {
planToday.toggle(planToday.item(button.dataset.planRemove));
qs('#plan-today-error').textContent = '';
@@ -739,7 +847,11 @@
}));
}
function closePlanToday() {
function closePlanToday(navigate = true) {
if (navigate) {
taskOverlayHistory.close();
return;
}
planToday.cancel();
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
@@ -768,12 +880,68 @@
},
});
function openPlanToday(trigger) {
function canPreviewPlanItem(item) {
if (!item || !offlineWorkMode) return Boolean(item);
const login = planningOwnerLogin || confirmedOwnerLogin ||
String(offlineWorkStore.load()?.user?.login || '').trim();
return Boolean(offlineWorkStore.loadDetail(login, item));
}
function openPlanPreviewDetail(item, trigger) {
qs('#plan-today-sheet').hidden = true;
qs('#plan-preview-actions').hidden = false;
if (offlineWorkMode) {
openRoutedWork(item, trigger);
} else if (item.kind === 'update' && item.has_update) {
updateTrigger = trigger;
notificationReader.open(item, lastMyWork);
} else if (item.is_review || item.kind === 'review') {
reviewTrigger = trigger;
openReviewSheet(item, trigger);
} else if (item.kind === 'issue') {
issueTrigger = trigger;
openIssueSheet(item, trigger);
} else if (item.kind === 'pull') {
pullTrigger = trigger;
openPullSheet(item, trigger);
}
}
const planTodayPreview = createPlanTodayPreview({
planner: planToday,
identity: item => todayWork.identity(item),
getScroll: () => qs('.plan-today-panel').scrollTop,
setScroll: value => requestAnimationFrame(() => { qs('.plan-today-panel').scrollTop = value; }),
onOpen: openPlanPreviewDetail,
onClose: (_item, trigger) => {
closeOpenWorkSheets();
qs('#plan-preview-actions').hidden = true;
qs('#plan-today-sheet').hidden = false;
renderPlanToday();
requestAnimationFrame(() => trigger?.focus());
},
});
let addPlanPreviewOnReturn = false;
qs('#back-to-plan').addEventListener('click', () => {
addPlanPreviewOnReturn = false;
taskOverlayHistory.close();
});
qs('#add-plan-preview').addEventListener('click', () => {
addPlanPreviewOnReturn = true;
taskOverlayHistory.close();
});
function openPlanToday(trigger, navigate = true) {
if (!planningOwnerLogin) {
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
return;
}
planTodayTrigger = trigger;
if (trigger) planTodayTrigger = trigger;
if (navigate) {
taskOverlayHistory.open('plan-today');
return;
}
planToday.open(todayMyWork, activeMyWork);
qs('#plan-today-error').textContent = '';
qs('#plan-today-sheet').hidden = false;
@@ -981,7 +1149,7 @@
);
}
function refreshMyWorkView() {
function refreshMyWorkView({ reconcileSession = true } = {}) {
lastDrafts = listDrafts();
const partitioned = laterWork.partition(lastMyWork, {
pruneMissing: !Object.values(workPagination).some(page => page?.has_more),
@@ -1016,7 +1184,7 @@
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileTaskDock.updateAttention(counts.attention);
mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
qs('#active-work-queue').textContent = activeQueue.firstChild.textContent.trim() +
' (' + (counts[selectedWorkFilter] || 0) + ')';
@@ -1040,7 +1208,8 @@
'No assigned work, review requests, or unread updates.';
updateWorkPaginationControls();
renderMyWork();
if (workSession.active()) workSession.reconcile();
if (reconcileSession && workSession.active()) workSession.reconcile();
updateWorkSessionActions();
}
function activeWorkStreams() {
@@ -1215,7 +1384,7 @@
const todayActions = selectedWorkFilter === 'today' ?
'<div class="today-actions" aria-label="Reorder Today"><button type="button" data-today-move="up" data-work-index="' + index + '"' + (todayPosition.can_up ? '' : ' disabled') + '>Move up</button><button type="button" data-today-move="down" data-work-index="' + index + '"' + (todayPosition.can_down ? '' : ' disabled') + '>Move down</button><button type="button" data-today-remove data-work-index="' + index + '">Remove from Today</button></div>' :
'<div class="today-actions"><button type="button" data-today-add data-work-index="' + index + '"' + (alreadyToday ? ' disabled' : planningDisabled) + '>' + (alreadyToday ? 'Added to Today' : 'Add to Today') + '</button></div>';
const planningActions = todayActions + laterActions;
const planningActions = '<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
if (item.is_review) {
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
@@ -1229,6 +1398,7 @@
}).join('') : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
cardPlanning.wire();
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@@ -1528,6 +1698,7 @@
qs('#issue-edit-form').hidden = true;
qs('#issue-edit-status').textContent = '';
qs('#close-issue').disabled = false;
qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';
qs('#close-issue-sheet').focus();
try {
const detail = offlineDetail || await issueController.load(item);
@@ -1679,6 +1850,7 @@
qs('#pull-ci-state').textContent = 'CI unknown';
qs('#pull-merge-state').textContent = 'Review data not loaded';
qs('#merge-pull').disabled = true;
qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';
qs('#retry-pull-load').hidden = true;
qs('#open-pull-gitea').href = item.url || '#';
qs('#close-pull-sheet').focus();
@@ -1740,7 +1912,10 @@
'<div>' + (item.labels || []).map(label => '<span class="pill">' + escapeHtml(label) + '</span>').join(' ') +
'</div><button type="button" data-preview-index="' + index + '" aria-expanded="' + expanded +
'" aria-controls="' + detailId + '">' + (expanded ? 'Hide details' : 'View details') + '</button>' +
detail + '<button type="button" data-claim-index="' + index + '">Assign to me</button></article>';
detail + '<div class="find-work-claim-actions"><button type="button" data-claim-index="' + index +
'">Assign</button><button type="button" data-claim-queue-index="' + index +
'">Queue Today</button><button type="button" data-claim-start-index="' + index +
'">Start now</button></div></article>';
}).join('') : '<div class="muted">No unassigned issues are available on this page.</div>';
list.querySelectorAll('[data-preview-index]').forEach(button => {
button.addEventListener('click', () => {
@@ -1760,12 +1935,7 @@
button.disabled = true;
try {
const confirmed = await findWorkController.claim(item);
lastContextSnapshot = lastContextSnapshot || { user: {}, repos: [], issues: [], pull_requests: [] };
lastContextSnapshot.issues = [confirmed].concat(lastContextSnapshot.issues || []);
lastMyWork = buildMyWork(lastContextSnapshot);
const claimed = lastMyWork.find(work =>
work.kind === 'issue' && work.repository === confirmed.repository && work.number === confirmed.number
);
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
qs('#my-work-action-status').textContent = confirmed.repository + '#' + confirmed.number + ' assigned to you.';
@@ -1777,6 +1947,41 @@
}
});
});
list.querySelectorAll('[data-claim-queue-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimQueueIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
const outcome = await assignAndStart.run(item, { destination:'queue' });
if (outcome === 'queued') {
(qs('[data-claim-queue-index]') || qs('#close-find-work')).focus();
}
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
list.querySelectorAll('[data-claim-start-index]').forEach(button => {
button.addEventListener('click', async () => {
const item = findWorkController.items()[Number(button.dataset.claimStartIndex)];
if (!item) return;
const claimButtons = button.closest('.find-work-card')
.querySelectorAll('[data-claim-index], [data-claim-queue-index], [data-claim-start-index]');
claimButtons.forEach(action => { action.disabled = true; });
try {
await assignAndStart.run(item);
} catch (error) {
qs('#find-work-status').textContent = error.message + ' Nothing was added to Today; retry assignment.';
claimButtons.forEach(action => { action.disabled = false; });
button.focus();
}
});
});
}
async function openFindWorkSheet(navigate = true) {
@@ -1837,6 +2042,27 @@
};
}
let issueCaptureRepositories = [];
let nextIssueRepositoryPage = 2;
let moreIssueRepositoriesAvailable = false;
function appendIssueRepositories(items) {
const select = qs('#create-issue-repository');
const selected = select.value;
const known = new Set(issueCaptureRepositories);
(Array.isArray(items) ? items : []).forEach(item => {
const repository = String(item?.full_name || '').trim();
if (!repository || known.has(repository)) return;
known.add(repository);
issueCaptureRepositories.push(repository);
const option = document.createElement('option');
option.value = repository;
option.textContent = repository;
select.appendChild(option);
});
if (selected) select.value = selected;
}
let duplicateCheckTimer = null;
function renderIssueDuplicates(state) {
if (state.status === 'stale') return;
@@ -1959,13 +2185,18 @@
return;
}
const captureDraft = issueCapture.loadDraft();
const repositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (captureDraft.repository && !repositories.includes(captureDraft.repository)) {
repositories.unshift(captureDraft.repository);
const initialRepositories = (lastContextSnapshot?.repos || []).map(repository => repository.full_name).filter(Boolean);
if (!issueCaptureRepositories.length) issueCaptureRepositories = initialRepositories.slice();
if (captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)) {
issueCaptureRepositories.unshift(captureDraft.repository);
}
qs('#create-issue-repository').innerHTML = repositories.map(repository =>
qs('#create-issue-repository').innerHTML = issueCaptureRepositories.map(repository =>
'<option value="' + escAttr(repository) + '">' + escapeHtml(repository) + '</option>'
).join('');
if (nextIssueRepositoryPage === 2) {
moreIssueRepositoriesAvailable = lastContextSnapshot?.repository_pagination?.has_more === true;
}
qs('#load-more-issue-repositories').hidden = !moreIssueRepositoriesAvailable;
if (captureDraft.repository) qs('#create-issue-repository').value = captureDraft.repository;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
@@ -1973,9 +2204,9 @@
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = repositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !repositories.length;
qs('#create-and-start-issue').disabled = !repositories.length || !createAndStart.available();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#submit-new-issue').disabled = !issueCaptureRepositories.length;
qs('#create-and-start-issue').disabled = !issueCaptureRepositories.length || !createAndStart.available();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
qs('#create-issue-title').focus();
@@ -2444,6 +2675,7 @@
const sheet = qs('#search-preview');
const status = qs('#search-preview-status');
const claimButton = qs('#claim-search-result');
const startButton = qs('#start-search-result');
if (state.status === 'closed') {
sheet.classList.remove('open');
return;
@@ -2451,6 +2683,8 @@
sheet.classList.add('open');
claimButton.hidden = true;
claimButton.disabled = false;
startButton.hidden = true;
startButton.disabled = false;
if (state.status === 'loading') {
searchPreviewDetail = null;
qs('#search-preview-key').textContent = state.item.repository + ' #' + state.item.number;
@@ -2480,6 +2714,10 @@
claimButton.hidden = !(detail.claimable || (detail.assigned_to_me && detail.kind === 'issue'));
claimButton.textContent = detail.assigned_to_me ? 'Open in My Work' : 'Assign to me';
claimButton.disabled = state.status === 'claiming';
startButton.hidden = !(detail.kind === 'issue' && detail.state === 'open' &&
(detail.claimable || detail.assigned_to_me));
startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start';
startButton.disabled = state.status === 'claiming';
status.textContent = state.status === 'claiming' ? 'Assigning this issue to you…' :
(state.status === 'claimed' ? 'Assignment confirmed. Opening My Work…' :
(detail.claimable ? 'This issue is open and unassigned.' :
@@ -2494,6 +2732,26 @@
),
onState: renderSearchPreview,
});
const searchAssignAndStart = createAssignAndStart({
available: createAndStart.available,
claim: detail => searchPreview.claim(detail),
start: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
return createAndStart.complete(claimed);
},
recover: confirmed => {
const claimed = acceptClaimedIssue(confirmed);
taskOverlayHistory.leave();
refreshMyWorkView();
openRoutedWork(claimed, qs('#open-palette'));
},
announce: message => {
qs('#search-preview-status').textContent = message;
qs('#my-work-action-status').textContent = message;
},
});
const mobileSearchViewport = createMobileSearchViewport({
palette: qs('#cmd-palette'),
results: qs('#cmd-results'),
@@ -2595,9 +2853,16 @@
mobileSearchViewport.close();
qs('#open-palette').focus();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn) planTodayPreview.close({ add:true });
else planTodayPreview.close();
addPlanPreviewOnReturn = false;
}
if (previous === 'plan-today' && kind !== 'plan-today' && kind !== 'plan-today-preview') closePlanToday(false);
if (kind === 'new' && previous !== 'new') openCreateIssueSheet(false);
if (kind === 'find' && previous !== 'find') openFindWorkSheet(false);
if (kind === 'search' && previous !== 'search-preview') openCommandPalette(false);
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
},
});
taskOverlayHistory.start();
@@ -2695,6 +2960,16 @@
qs('#search-preview-status').textContent = error.message + ' Retry assignment.';
}
});
qs('#start-search-result').addEventListener('click', async () => {
const detail = searchPreviewDetail;
if (!detail || detail.kind !== 'issue' || detail.state !== 'open' ||
(!detail.claimable && !detail.assigned_to_me)) return;
try {
await searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me });
} catch (error) {
qs('#search-preview-status').textContent = error.message + ' Retry assignment and start.';
}
});
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
qs('#find-work').addEventListener('click', openFindWorkSheet);
qs('#close-find-work').addEventListener('click', closeFindWorkSheet);
@@ -2766,6 +3041,26 @@
saveIssueCaptureDraft();
scheduleIssueDuplicateCheck();
});
qs('#load-more-issue-repositories').addEventListener('click', async event => {
const button = event.currentTarget;
const status = qs('#create-issue-repository-status');
saveIssueCaptureDraft();
button.disabled = true;
status.textContent = 'Loading more repositories…';
try {
const payload = await issueCapture.loadRepositoryPage(nextIssueRepositoryPage);
appendIssueRepositories(payload.items);
nextIssueRepositoryPage = payload.page + 1;
moreIssueRepositoriesAvailable = payload.has_more;
button.hidden = !moreIssueRepositoriesAvailable;
status.textContent = payload.items.length ?
'More repositories are available in the selector.' : 'All accessible repositories are loaded.';
} catch (_error) {
status.textContent = 'Repositories could not be loaded. Your draft is safe; retry.';
} finally {
button.disabled = false;
}
});
qs('#create-issue-label-list').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-milestone').addEventListener('change', saveIssueCaptureDraft);
qs('#create-issue-anyway').addEventListener('click', () => {
@@ -3139,8 +3434,14 @@
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'issue' && item.repository === closing.repository && item.number === closing.number)
);
refreshMyWorkView();
qs('#my-work-action-status').textContent = closing.key + ' closed.';
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
if (workSession.active()) workSession.complete();
if (continuingSession && workSession.active()) {
qs('#my-work-action-status').textContent = closing.key + ' closed. Next work item opened.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = closing.key + ' closed.';
}
} catch (error) {
qs('#issue-sheet-status').textContent = error.message + ' The issue remains in My Work; retry.';
button.disabled = false;
@@ -3225,8 +3526,14 @@
lastMyWork = lastMyWork.filter(item =>
!(item.kind === 'pull' && item.repository === merging.repository && item.number === merging.number)
);
refreshMyWorkView();
qs('#my-work-action-status').textContent = merging.key + ' merged.';
refreshMyWorkView({ reconcileSession:false });
const continuingSession = workSession.active();
if (workSession.active()) workSession.complete();
if (continuingSession && workSession.active()) {
qs('#my-work-action-status').textContent = merging.key + ' merged. Next work item opened.';
} else if (!continuingSession) {
qs('#my-work-action-status').textContent = merging.key + ' merged.';
}
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; refresh and retry.';
button.disabled = false;
@@ -3549,15 +3856,13 @@
});
qs('#save-today-plan').addEventListener('click', () => {
const result = planToday.commit();
if (result === 'saved') closePlanToday();
if (result === 'saved') taskOverlayHistory.leave();
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
});
qs('#save-and-start-today').addEventListener('click', () => {
const result = planToday.commit({ start:true });
if (result === 'saved') {
qs('#plan-today-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
} else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
if (result === 'saved') taskOverlayHistory.leave();
else qs('#plan-today-error').textContent = 'Could not save the plan on this device. Free storage and retry.';
});
qs('#start-work-session').addEventListener('click', () => {
const sessionItems = selectedWorkFilter === 'today' ? todayMyWork : filterMyWork(lastMyWork, selectedWorkFilter);
@@ -3567,6 +3872,18 @@
}
workSession.start();
});
qs('#resume-today-session').addEventListener('click', () => {
qs('[data-work-filter="today"]').click();
if (!workSession.resume()) {
qs('#my-work-action-status').textContent = 'Saved Today session is no longer available.';
updateWorkSessionActions();
}
});
qs('#end-today-session').addEventListener('click', () => {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
qs('#start-work-session').focus();
});
document.querySelectorAll('[data-work-session-previous]').forEach(button =>
button.addEventListener('click', () => workSession.previous())
);
+19 -2
View File
@@ -50,6 +50,8 @@
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button>
</div>
@@ -194,6 +196,11 @@
</section>
</div>
<div class="plan-preview-actions" id="plan-preview-actions" hidden>
<button id="back-to-plan" type="button">Back to plan</button>
<button id="add-plan-preview" type="button">Add to Today &amp; back</button>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
@@ -216,7 +223,10 @@
<div id="search-preview-meta" class="row"></div>
<div id="search-preview-body" class="search-preview-body markdown-content"></div>
<div class="search-preview-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<div class="search-preview-primary-actions">
<button id="claim-search-result" type="button" hidden>Assign to me</button>
<button id="start-search-result" type="button" hidden>Assign &amp; start</button>
</div>
<a id="open-search-result-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
</div>
</section>
@@ -359,6 +369,8 @@
<label for="create-issue-repository">Repository
<select id="create-issue-repository" required></select>
</label>
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
<label for="create-issue-title">Title
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
</label>
@@ -558,7 +570,7 @@
</dialog>
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
@@ -582,8 +594,10 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/today-work.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/later-work.js"></script>
@@ -595,12 +609,15 @@
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
<script src="static/create-and-start.js"></script>
<script src="static/assign-and-start.js"></script>
<script src="static/queue-today.js"></script>
<script src="static/pull-sheet.js"></script>
<script src="static/review-sheet.js"></script>
<script src="static/work-route.js"></script>
<script src="static/task-overlay-history.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-search-viewport.js"></script>
+16 -3
View File
@@ -7,6 +7,7 @@
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
let workMode = 'work';
function select(name) {
Object.entries(buttons).forEach(([key, button]) => {
@@ -34,9 +35,17 @@
refreshVisibility();
}
function updateAttention(count) {
function updateWork(mode, count) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
work: ['Work', 'Work'],
};
workMode = labels[mode] ? mode : 'work';
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
@@ -44,10 +53,14 @@
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
total ? 'Work, ' + total + ' items need attention' : 'Work'
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
);
}
}
return {start, refreshVisibility, updateAttention};
function updateAttention(count) {
updateWork(workMode, count);
}
return {start, refreshVisibility, updateAttention, updateWork};
});
+22
View File
@@ -0,0 +1,22 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileWorkEntry = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
return 'work';
}
function open() {
const current = mode();
if (current === 'continue') options.continueToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else options.openFallback();
return current;
}
return {mode, open};
});
+87 -1
View File
@@ -472,10 +472,67 @@ function workIdentity(item) {
return [kind, repository, number, notification].join(':');
}
function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', onOpen, onProgress, onFinish }) {
function createWorkSessionCheckpoint({
storage,
getLogin,
onError = () => {},
key = 'stackchain.today-session.v1',
}) {
const login = () => String(getLogin() || '').trim();
let errorReported = false;
const reportError = error => {
if (errorReported) return;
errorReported = true;
onError(error);
};
const read = () => {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || typeof value.identity !== 'string' ||
!Number.isInteger(value.index) || value.index < 0) return null;
return value;
} catch (error) {
reportError(error);
return null;
}
};
return {
read,
save(identity, index) {
const owner = login();
if (!owner) return false;
try {
storage.setItem(key, JSON.stringify({ version:1, login:owner, identity, index }));
return true;
} catch (error) {
reportError(error);
return false;
}
},
clear() {
if (!read()) return false;
try {
storage.removeItem(key);
return true;
} catch (error) {
reportError(error);
return false;
}
},
};
}
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@@ -485,9 +542,12 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
can_next: index < items.length - 1,
});
const finish = () => {
const clearCheckpoint = durable;
running = false;
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
onFinish();
return false;
};
@@ -495,6 +555,7 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@@ -502,10 +563,34 @@ function createWorkSession({ getItems, getFilter, getMilestone = () => 'all', on
return {
active: () => running,
checkpointed: () => running && durable,
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
reopen() {
if (!running) return false;
const items = queue();
const index = items.findIndex(item => workIdentity(item) === currentIdentity);
if (index < 0) return false;
currentIndex = index;
report(items, index);
onOpen(items[index]);
return true;
},
resume() {
const saved = checkpoint?.read();
if (!saved) return false;
durable = true;
const items = queue();
if (!items.length) return finish();
running = true;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return openAt(items, exact >= 0 ? exact : Math.min(saved.index, items.length - 1));
},
start(item = null) {
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@@ -612,6 +697,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
buildMyWork.workIdentity = workIdentity;
buildMyWork.replaceIssueLabels = replaceIssueLabels;
buildMyWork.replaceIssueContent = replaceIssueContent;
+45
View File
@@ -0,0 +1,45 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.createPlanTodayPreview = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
return function createPlanTodayPreview({
planner,
identity,
getScroll = () => 0,
setScroll = () => {},
onOpen = () => {},
onClose = () => {},
}) {
let current = null;
function open(item, trigger = null) {
if (!item || !identity?.(item) || !planner?.snapshot().open) return false;
current = { item, trigger, scroll:Number(getScroll()) || 0 };
onOpen(item, trigger);
return true;
}
function close({ add = false } = {}) {
if (!current) return 'closed';
const { item, trigger, scroll } = current;
let result = 'returned';
if (add) {
const id = identity(item);
result = planner.snapshot().ids.includes(id) ? 'already-added' : planner.toggle(item);
}
current = null;
setScroll(scroll);
onClose(item, trigger);
return result;
}
function snapshot() {
return current ? { open:true, item:current.item, trigger:current.trigger, scroll:current.scroll } : { open:false };
}
return { open, close, snapshot };
};
});
+17
View File
@@ -0,0 +1,17 @@
function createQueueToday({ todayWork, todaySync, refresh, warm }) {
return function queueToday(issue) {
const added = todayWork.add(issue);
if (added !== 'added' && added !== 'exists') return added;
if (added === 'added' && !todaySync.enqueue('add', todayWork.identity(issue))) {
refresh();
warm();
return 'sync-unavailable';
}
refresh();
if (added === 'added') todaySync.flush();
warm();
return 'queued';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createQueueToday;
+47 -3
View File
@@ -1,6 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v59';
const CACHE = 'stackchain-dashboard-shell-v67';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -24,8 +25,10 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/card-planning.js',
BASE + 'static/today-work.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/update-ownership.js',
BASE + 'static/later-work.js',
@@ -37,12 +40,15 @@ const SHELL = [
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
BASE + 'static/create-and-start.js',
BASE + 'static/assign-and-start.js',
BASE + 'static/queue-today.js',
BASE + 'static/pull-sheet.js',
BASE + 'static/review-sheet.js',
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-search-viewport.js',
@@ -103,6 +109,41 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
}
async function expiredOfflineResponse() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
return new Response(
'Your Stackchain session expired. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
return cache.match(BASE);
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
@@ -176,6 +217,9 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
@@ -214,13 +258,13 @@ self.addEventListener('fetch', event => {
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
+109 -6
View File
@@ -81,7 +81,9 @@
devicesSheet.hidden = true;
devicesButton?.focus();
});
boundary.resumeQueuedWork();
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
});
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
@@ -93,6 +95,9 @@
promptAuthorization = () => null,
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
@@ -104,10 +109,38 @@
location.replace(base + 'login?reason=session-revoked');
}
async function handleSessionExpiry() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
onExpired();
location.replace(base + 'login?reason=session-expired');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
}
function storedOfflineLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
scheduleOfflineExpiry(expiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
}
function csrfToken() {
@@ -122,6 +155,56 @@
catch (_error) { return false; }
}
function isDashboardApi(input) {
try {
const url = new URL(String(input?.url || input), origin);
return url.origin === origin && url.pathname.startsWith(base + 'api/v1/');
} catch (_error) { return false; }
}
async function fetchWithDeadline(input, options, method, phase = 'request') {
if (!isDashboardApi(input)) return fetchImpl(input, options);
const controller = new AbortController();
const callerSignal = options.signal || input?.signal;
let timeout;
let rejectCancellation;
const cancellation = new Promise((_resolve, reject) => { rejectCancellation = reject; });
const cancelFromCaller = () => {
const error = callerSignal.reason || new DOMException('The request was aborted.', 'AbortError');
error.source = 'caller';
controller.abort(error);
rejectCancellation(error);
};
if (callerSignal?.aborted) cancelFromCaller();
else callerSignal?.addEventListener?.('abort', cancelFromCaller, { once: true });
const deadline = new Promise((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(
SAFE_METHODS.has(method)
? 'Request timed out. Try again.'
: 'Request timed out. Refresh to verify the outcome before retrying.'
);
error.name = 'TimeoutError';
error.method = method;
error.outcome = 'unknown';
error.safeToRetry = SAFE_METHODS.has(method);
error.phase = phase;
controller.abort(error);
reject(error);
}, requestTimeoutMs);
});
try {
return await Promise.race([
Promise.resolve().then(() => fetchImpl(input, { ...options, signal: controller.signal })),
deadline,
cancellation,
]);
} finally {
clearTimeout(timeout);
callerSignal?.removeEventListener?.('abort', cancelFromCaller);
}
}
async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options };
@@ -131,7 +214,7 @@
if (csrf) headers.set('X-CSRF-Token', csrf);
requestOptions.headers = headers;
}
const response = await fetchImpl(input, requestOptions);
const response = await fetchWithDeadline(input, requestOptions, method);
if (response.status === 428 && isSameOrigin(input) && allowStepUp) {
const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {};
@@ -147,7 +230,7 @@
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', {
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
@@ -155,7 +238,7 @@
action: detail.action,
target: detail.target,
}),
});
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const grant = await authorization.json().catch(() => ({}));
if (!grant.grant) return response;
@@ -171,13 +254,32 @@
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
await publishOfflineLease(payload.expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
}
if (expiresAt) await handleSessionExpiry();
return false;
}
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
@@ -287,6 +389,7 @@
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
};
});
+1 -1
View File
@@ -5,7 +5,7 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
const allowed = new Set(['new', 'find', 'search', 'search-preview']);
const allowed = new Set(['new', 'find', 'search', 'search-preview', 'plan-today', 'plan-today-preview']);
return function createTaskOverlayHistory({ history, eventTarget, onChange }) {
let active = allowed.has(history.state?.taskOverlay) ? history.state.taskOverlay : null;
+45
View File
@@ -0,0 +1,45 @@
from starlette.datastructures import Headers
from starlette.middleware.gzip import GZipResponder, IdentityResponder
from starlette.types import ASGIApp, Receive, Scope, Send
def _quality(accept_encoding: str, coding: str) -> float:
qualities: dict[str, float] = {}
for entry in accept_encoding.split(","):
parts = [part.strip() for part in entry.split(";")]
name = parts[0].lower()
if not name:
continue
quality = 1.0
for parameter in parts[1:]:
key, separator, value = parameter.partition("=")
if separator and key.strip().lower() == "q":
try:
quality = float(value.strip())
except ValueError:
quality = 0.0
qualities[name] = quality
return qualities.get(coding, qualities.get("*", 0.0))
class NegotiatedGZipMiddleware:
"""Compress substantial responses only when the client permits gzip."""
def __init__(self, app: ASGIApp, minimum_size: int = 500, compresslevel: int = 9):
self.app = app
self.minimum_size = minimum_size
self.compresslevel = compresslevel
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
accepted = Headers(scope=scope).get("Accept-Encoding", "")
if _quality(accepted, "gzip") > 0:
responder: ASGIApp = GZipResponder(
self.app, self.minimum_size, compresslevel=self.compresslevel
)
else:
responder = IdentityResponder(self.app, self.minimum_size)
await responder(scope, receive, send)
+50 -2
View File
@@ -133,6 +133,14 @@ class WorkItems(list[dict]):
self.pagination = pagination
class RepositoryItems(list[dict]):
"""A list-compatible repository page carrying truthful pagination metadata."""
def __init__(self, items: list[dict], pagination: dict):
super().__init__(items)
self.pagination = pagination
class WorkRouteUnavailableError(ValueError):
"""Raised when a shared route no longer belongs in the current user's queue."""
@@ -261,8 +269,48 @@ async def current_user() -> dict:
return await fetch("user")
async def repos() -> list[dict]:
return await fetch("user/repos?limit=50")
async def repo_page(page: int = 1, limit: int = 50) -> dict:
"""Load one bounded page of repositories available to the current user."""
response = await _get_client().get(
"/api/v1/user/repos",
headers=_auth(),
params={"page": page, "limit": limit},
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, list):
raise ValueError("Gitea repository response was not a list")
items = [item for item in payload if isinstance(item, dict)]
try:
total = max(len(items), int(response.headers.get("X-Total-Count", len(items))))
except (TypeError, ValueError):
total = len(items)
return {
"items": items,
"page": page,
"total": total,
"has_more": page * limit < total,
}
async def repos() -> RepositoryItems:
result = await repo_page()
return RepositoryItems(
result["items"],
{key: result[key] for key in ("page", "total", "has_more")},
)
async def repository_access(repository: str) -> dict | None:
"""Return a repository only when the authenticated user can access it."""
response = await _get_client().get(
f"/api/v1/repos/{repository}", headers=_auth()
)
if response.status_code == 404:
return None
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, dict) else None
WORK_SEARCHES = {
+44 -23
View File
@@ -19,6 +19,7 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import dashboard_auth, gitea_proxy
from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import (
activity_events,
current_user,
@@ -67,6 +68,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
app.add_middleware(RequestBodyLimitMiddleware, limit_for=request_body_limit)
app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
@@ -502,6 +504,9 @@ def _context_payload(user_data, repo_data, issues_data, prs_data) -> dict:
and all(field in p for field in ("id", "number", "title", "state", "html_url"))
]
payload = compute(user_model, repo_models, issue_models, pr_models).model_dump()
repository_pagination = getattr(repo_data, "pagination", None)
if isinstance(repository_pagination, dict):
payload["repository_pagination"] = repository_pagination
pagination = {}
pagination.update(getattr(issues_data, "pagination", {}))
pagination.update(getattr(prs_data, "pagination", {}))
@@ -875,10 +880,13 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
@app.get("/api/v1/session")
async def session_status(request: Request):
session = request.state.dashboard_session
return {
payload = {
"authenticated": session is not None,
"csrf_token": session.csrf if session is not None else "",
}
if session is not None:
payload["expires_at"] = session.expires_at
return payload
def _today_store() -> TodayStore:
@@ -1210,6 +1218,36 @@ async def context() -> JSONResponse:
return JSONResponse(_context_payload(user_data, repo_data, issues_data, prs_data))
@app.get("/api/v1/repositories")
async def repository_page(
page: int = Query(default=1, ge=1, le=1000),
limit: int = Query(default=50, ge=1, le=50),
) -> JSONResponse:
"""Return one bounded repository page for lazy issue-capture selection."""
try:
result = await asyncio.wait_for(
gitea_proxy.repo_page(page=page, limit=limit),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
items = [
Repo(
id=item["id"], name=item["name"], full_name=item["full_name"],
description=item.get("description") or "", url=item["html_url"],
updated_at=item.get("updated_at", ""),
).model_dump()
for item in result.get("items", [])
if isinstance(item, dict)
and all(field in item for field in ("id", "name", "full_name", "html_url"))
]
return JSONResponse({**result, "items": items})
except Exception:
return JSONResponse(
{"error": "Repositories could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
@app.get("/api/v1/background-identity")
async def background_identity() -> JSONResponse:
"""Return only the account key required to safely drain a browser outbox."""
@@ -2188,13 +2226,7 @@ async def repository_milestones(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_milestones():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_milestones(repository)
@@ -2217,13 +2249,7 @@ async def repository_labels(owner: str, repo: str):
repository = f"{owner}/{repo}"
async def load_labels():
available = await gitea_proxy.repos()
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if repository not in accessible:
if await gitea_proxy.repository_access(repository) is None:
raise HTTPException(status_code=404, detail="Repository not found")
return await gitea_proxy.repo_labels(repository)
@@ -2249,16 +2275,11 @@ async def create_assigned_issue(
repository = f"{owner}/{repo}"
async def create_issue():
user, available = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repos()
user, accessible = await asyncio.gather(
gitea_proxy.current_user(), gitea_proxy.repository_access(repository)
)
login = user.get("login") if isinstance(user, dict) else None
accessible = {
item.get("full_name")
for item in (available if isinstance(available, list) else [])
if isinstance(item, dict)
}
if not login or repository not in accessible:
if not login or accessible is None:
raise HTTPException(status_code=404, detail="Repository not found")
if creation.label_ids:
available_labels = await gitea_proxy.repo_labels(repository)
+276
View File
@@ -0,0 +1,276 @@
import json
import subprocess
from pathlib import Path
ASSIGN_AND_START = Path(__file__).parents[1] / "frontend" / "assign-and-start.js"
QUEUE_TODAY = Path(__file__).parents[1] / "frontend" / "queue-today.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_repeated_assign_and_start_taps_claim_once_then_start_confirmed_issue():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(ASSIGN_AND_START))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const issue={{repository:'stackchain/dashboard',number:411}};
const first=flow.run(issue);
const second=flow.run(issue);
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{available:true,calls,results,same:first===second}})));
}}
"""
assert run_node(script) == {
"available": True,
"calls": [
"claim:411",
"start:411",
"announce:Assigned, added to Today, and ready to work.",
],
"results": ["started", "started"],
"same": True,
}
def test_full_today_blocks_before_assignment_and_explains_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const flow=createAssignAndStart({{
available:()=>false,
claim:()=>{{calls.push('claim');return Promise.resolve({{}});}},
start:()=>calls.push('start'),
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run({{repository:'stackchain/dashboard',number:411}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "full",
"calls": ["announce:Today is full—remove an item before assigning this issue."],
}
def test_confirmed_assignment_recovers_in_issue_detail_when_today_start_fails():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:411,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned to you, but Today could not start. The issue is open so you can recover.",
"recover:411",
],
}
def test_failed_assignment_does_not_start_today_and_can_be_retried():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];let attempt=0;
const issue={{repository:'stackchain/dashboard',number:411}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{attempt+=1;calls.push('claim:'+attempt);return attempt===1 ? Promise.reject(new Error('conflict')) : Promise.resolve(issue);}},
start:()=>{{calls.push('start');return 'started';}},
recover:()=>calls.push('recover'),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue).catch(error=>{{
calls.push('error:'+error.message);
return flow.run(issue);
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": "started",
"calls": [
"claim:1",
"error:conflict",
"claim:2",
"start",
"announce:Assigned, added to Today, and ready to work.",
],
}
def test_already_owned_search_result_starts_without_claiming():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:413,assigned_to_me:true}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>{{calls.push('claim');return Promise.resolve(issue);}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{alreadyOwned:true}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "started",
"calls": [
"start:413",
"announce:Added to Today and ready to work.",
],
}
def test_queue_today_claims_once_without_starting_the_active_session():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
let release;
const claimResult=new Promise(resolve=>{{release=resolve;}});
const issue={{repository:'stackchain/dashboard',number:415}};
const flow=createAssignAndStart({{
available:()=>true,
claim:item=>{{calls.push('claim:'+item.number);return claimResult;}},
start:item=>{{calls.push('start:'+item.number);return 'started';}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
const first=flow.run(issue, {{destination:'queue'}});
const second=flow.run(issue, {{destination:'queue'}});
release({{...issue,assignees:['timmy']}});
Promise.all([first,second]).then(results=>process.stdout.write(JSON.stringify({{
calls,results,same:first===second
}})));
"""
assert run_node(script) == {
"calls": [
"claim:415",
"queue:415",
"announce:Assigned and queued in Today. Keep finding work when ready.",
],
"results": ["queued", "queued"],
"same": True,
}
def test_queue_today_persists_syncs_and_warms_without_a_session_side_effect():
script = f"""
const fs=require('fs');
if (!fs.existsSync({json.dumps(str(QUEUE_TODAY))})) {{
process.stdout.write(JSON.stringify({{available:false}}));
}} else {{
const createQueueToday=require({json.dumps(str(QUEUE_TODAY))});
const calls=[];
const issue={{kind:'issue',repository:'stackchain/dashboard',number:415}};
const queue=createQueueToday({{
todayWork:{{identity:()=> 'issue:stackchain/dashboard:415:',add:()=>{{calls.push('add');return 'added';}}}},
todaySync:{{enqueue:(action,id)=>{{calls.push(action+':'+id);return true;}},flush:()=>calls.push('flush')}},
refresh:()=>calls.push('refresh'),
warm:()=>calls.push('warm'),
}});
process.stdout.write(JSON.stringify({{available:true,result:queue(issue),calls}}));
}}
"""
assert run_node(script) == {
"available": True,
"result": "queued",
"calls": [
"add",
"add:issue:stackchain/dashboard:415:",
"refresh",
"flush",
"warm",
],
}
def test_queue_today_reports_assignment_when_local_planning_needs_recovery():
script = f"""
const createAssignAndStart=require({json.dumps(str(ASSIGN_AND_START))});
const calls=[];
const issue={{repository:'stackchain/dashboard',number:415,assignees:['timmy']}};
const flow=createAssignAndStart({{
available:()=>true,
claim:()=>Promise.resolve(issue),
start:()=> 'started',
queue:()=> 'sync-unavailable',
recover:item=>calls.push('recover:'+item.number),
announce:message=>calls.push('announce:'+message),
}});
flow.run(issue, {{destination:'queue'}}).then(result=>
process.stdout.write(JSON.stringify({{result,calls}}))
);
"""
assert run_node(script) == {
"result": "recovery",
"calls": [
"announce:Assigned and saved in Today on this device, but account sync is unavailable. The issue is open so you can recover.",
"recover:415",
],
}
def test_find_work_renders_phone_safe_assign_and_start_and_wires_offline_shell():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert "data-claim-start-index" in dashboard
assert "data-claim-queue-index" in dashboard
assert ">Start now</button>" in dashboard
assert ">Queue Today</button>" in dashboard
assert "const assignAndStart = createAssignAndStart({" in dashboard
assert "assignAndStart.run(item)" in dashboard
assert "assignAndStart.run(item, { destination:'queue' })" in dashboard
assert "queue: confirmed =>" in dashboard
assert "const queueToday = createQueueToday({" in dashboard
assert "return queueToday(claimed)" in dashboard
assert "createAndStart.available" in dashboard
assert "createAndStart.complete" in dashboard
assert ".find-work-claim-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
assert ".find-work-claim-actions [data-claim-start-index]" in css
assert "grid-column:1 / -1" in css
assert "BASE + 'static/assign-and-start.js'" in worker
assert "BASE + 'static/queue-today.js'" in worker
assert '<script src="static/queue-today.js"></script>' in html
+12
View File
@@ -243,6 +243,18 @@ def test_assigned_issue_preview_hands_off_to_existing_my_work_sheet():
assert "openPreviewIssueInMyWork" in html
def test_search_preview_offers_assign_and_start_for_eligible_issues():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
assert 'id="start-search-result"' in html
assert "startButton.textContent = detail.assigned_to_me ? 'Start in Today' : 'Assign & start'" in html
assert "const searchAssignAndStart = createAssignAndStart({" in html
assert "searchAssignAndStart.run(detail, { alreadyOwned: detail.assigned_to_me })" in html
assert ".search-preview-primary-actions" in css
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in css
def test_mobile_search_viewport_tracks_keyboard_geometry_without_leaking_listeners():
script = f"""
const createMobileSearchViewport = require({json.dumps(str(MOBILE_SEARCH_VIEWPORT))});
+22 -6
View File
@@ -254,12 +254,16 @@ async def test_session_status_reuses_the_middleware_validation(access_control, m
response = await client.get("/api/v1/session")
assert response.status_code == 200
assert response.json()["authenticated"] is True
payload = response.json()
assert payload["authenticated"] is True
assert isinstance(payload["expires_at"], int)
assert payload["expires_at"] > int(time.time())
assert response.headers["cache-control"] == "no-store"
assert lookups == 1
@pytest.mark.anyio
async def test_authenticated_session_status_exposes_only_csrf_proof(access_control):
async def test_authenticated_session_status_exposes_csrf_proof_and_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
@@ -267,14 +271,26 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr
)
response = await client.get("/api/v1/session")
payload = response.json()
assert response.status_code == 200
assert response.json() == {
"authenticated": True,
"csrf_token": client.cookies["stackchain_csrf"],
}
assert payload["authenticated"] is True
assert payload["csrf_token"] == client.cookies["stackchain_csrf"]
assert payload["expires_at"] > int(time.time())
assert "correct horse battery staple" not in response.text
@pytest.mark.anyio
async def test_anonymous_session_status_discloses_no_offline_lease(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
response = await client.get("/api/v1/session")
assert response.status_code == 401
assert response.json() == {"detail": "Authentication required"}
assert "expires_at" not in response.text
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target(
access_control, monkeypatch
+223 -5
View File
@@ -14,11 +14,13 @@ SESSION_JS = ROOT / "frontend" / "session.js"
def run_session_scenario(scenario: str) -> dict:
harness = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, replacedAfterDeletion: false, expiredAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }};
const storage = {{
values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]),
get length() {{ return this.values.size; }},
key(index) {{ return Array.from(this.values.keys())[index] || null; }},
getItem(key) {{ return this.values.get(key) || null; }},
setItem(key, value) {{ this.values.set(key, String(value)); }},
removeItem(key) {{ state.removed.push(key); this.values.delete(key); }},
}};
const boundary = createSessionBoundary({{
@@ -27,6 +29,7 @@ const boundary = createSessionBoundary({{
base: '/dashboard/',
fetchImpl: async (url, options = {{}}) => {{
state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }});
if (state.failFetch) throw new Error('offline');
const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }};
return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }});
}},
@@ -53,6 +56,8 @@ const boundary = createSessionBoundary({{
replace: value => state.replaced.push(value),
}},
onClearError: error => state.clearErrors.push(error.message),
onExpired: () => {{ state.expiredAfterDeletion = state.deletionCompleted; }},
setTimer: (_callback, delay) => {{ state.leaseDelay = delay; return 1; }},
confirmAction: message => {{ state.confirmations.push(message); return true; }},
promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }},
}});
@@ -62,6 +67,160 @@ const boundary = createSessionBoundary({{
return json.loads(result.stdout)
def test_same_origin_api_fetch_times_out_even_when_fetch_ignores_abort():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let aborted = false;
const boundary = createSessionBoundary({{
cookie: () => '',
origin: 'https://forge.example',
base: '/dashboard/',
requestTimeoutMs: 10,
fetchImpl: (_url, options = {{}}) => new Promise(resolve => {{
options.signal?.addEventListener('abort', () => {{ aborted = true; }});
setTimeout(() => resolve(new Response('{{}}', {{status:200}})), 80);
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
try {{ await boundary.fetch('/dashboard/api/v1/context'); }}
catch (error) {{
process.stdout.write(JSON.stringify({{
name:error.name, message:error.message, method:error.method,
outcome:error.outcome, aborted, elapsed:Date.now()-started,
}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "TimeoutError"
assert output["message"] == "Request timed out. Try again."
assert output["method"] == "GET"
assert output["outcome"] == "unknown"
assert output["aborted"] is True
assert output["elapsed"] < 70
def test_caller_abort_cancels_same_origin_api_fetch_without_waiting_for_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
let requestAborted = false;
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: (_url, options = {{}}) => new Promise(() => {{
options.signal.addEventListener('abort', () => {{ requestAborted = true; }});
}}),
location: {{ replace: () => {{}} }},
}});
(async () => {{
const started = Date.now();
const pending = boundary.fetch('/dashboard/api/v1/context', {{signal:caller.signal}});
setTimeout(() => caller.abort(new DOMException('Superseded', 'AbortError')), 10);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source, requestAborted, elapsed:Date.now()-started}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output["name"] == "AbortError"
assert output["message"] == "Superseded"
assert output["source"] == "caller"
assert output["requestAborted"] is True
assert output["elapsed"] < 100
def test_request_object_abort_signal_is_composed_with_the_api_deadline():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const caller = new AbortController();
const boundary = createSessionBoundary({{
cookie: () => '', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:1000,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
const request = new Request('https://forge.example/dashboard/api/v1/context', {{signal:caller.signal}});
const pending = boundary.fetch(request);
setTimeout(() => caller.abort(new DOMException('Request superseded', 'AbortError')), 5);
try {{ await pending; }} catch (error) {{
process.stdout.write(JSON.stringify({{name:error.name, message:error.message, source:error.source}}));
}}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "AbortError",
"message": "Request superseded",
"source": "caller",
}
def test_mutation_timeout_warns_that_the_server_outcome_may_be_ambiguous():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => new Promise(() => {{}}), location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/issues/9', {{method:'PATCH'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, message:error.message, method:error.method, safeToRetry:error.safeToRetry}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
output = json.loads(result.stdout)
assert output == {
"name": "TimeoutError",
"message": "Request timed out. Refresh to verify the outcome before retrying.",
"method": "PATCH",
"safeToRetry": False,
}
def test_fresh_authorization_request_uses_the_same_deadline_boundary():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
let calls = 0;
const boundary = createSessionBoundary({{
cookie: () => 'stackchain_csrf=proof', origin:'https://forge.example', base:'/dashboard/', requestTimeoutMs:5,
fetchImpl: () => {{
calls += 1;
if (calls === 1) return Promise.resolve(new Response(JSON.stringify({{detail:{{code:'step_up_required', action:'merge_pull', target:'stackchain/app#9'}}}}), {{status:428, headers:{{'Content-Type':'application/json'}}}}));
return new Promise(() => {{}});
}},
promptAuthorization: async () => 'operator-token', location: {{replace:() => {{}}}},
}});
(async () => {{
try {{ await boundary.fetch('/dashboard/api/v1/repos/stackchain/app/pulls/9/merge', {{method:'POST'}}); }}
catch (error) {{ process.stdout.write(JSON.stringify({{name:error.name, phase:error.phase, calls}})); }}
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True, timeout=2
)
assert json.loads(result.stdout) == {
"name": "TimeoutError",
"phase": "fresh-authorization",
"calls": 2,
}
def test_mutating_same_origin_fetch_receives_csrf_proof():
result = run_session_scenario(
"""
@@ -73,6 +232,46 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof"
def test_authenticated_session_status_persists_offline_lease_for_worker_and_expiry_timer():
result = run_session_scenario(
"""
state.responsePayload = {authenticated:true, csrf_token:'csrf-proof', expires_at:4102444800};
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.savedExpiry = storage.getItem('stackchain.session-expires-at');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is True
assert result["savedExpiry"] == "4102444800"
assert result["workerMessages"] == [
{"type": "stackchain-session-lease", "expiresAt": 4102444800}
]
assert result["leaseDelay"] > 0
def test_expired_stored_lease_purges_private_work_when_session_status_is_offline():
result = run_session_scenario(
"""
storage.setItem('stackchain.session-expires-at', '1');
state.failFetch = true;
const valid = await boundary.refreshOfflineLease();
state.valid = valid;
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario(
"""
@@ -106,7 +305,7 @@ process.stdout.write(JSON.stringify(state));
assert result["requests"][2]["body"] == result["requests"][0]["body"]
def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work():
def test_same_origin_unauthorized_response_purges_private_work_before_replacing_dashboard_once():
result = run_session_scenario(
"""
state.responseStatus = 401;
@@ -115,6 +314,7 @@ await Promise.all([
boundary.fetch('https://forge.example/dashboard/api/v1/context'),
]);
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
@@ -122,9 +322,12 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == [
"/dashboard/login?reason=session-expired"
]
assert result["remaining"] == ["stackchain.private", "gitea.preference"]
assert result["deletedDatabases"] == []
assert result["deletedCaches"] == []
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == ["stackchain-background-outbox-v1"]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
assert result["expiredAfterDeletion"] is True
assert result["replacedAfterDeletion"] is True
def test_remotely_revoked_response_clears_private_work_before_replacing_dashboard():
@@ -160,6 +363,21 @@ process.stdout.write(JSON.stringify(state));
assert result["replaced"] == ["/dashboard/login?reason=session-revoked"]
def test_worker_expiry_message_clears_window_storage_before_expired_login():
result = run_session_scenario(
"""
await boundary.handleServiceWorkerMessage({data:{type:'stackchain-session-expired'}});
state.remaining = Array.from(storage.values.keys());
state.replacedAfterDeletion = state.deletionCompleted && state.replaced.length === 1;
process.stdout.write(JSON.stringify(state));
"""
)
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
assert result["replacedAfterDeletion"] is True
def test_cross_origin_unauthorized_response_does_not_expire_dashboard_session():
result = run_session_scenario(
"""
+1 -1
View File
@@ -195,7 +195,7 @@ async def test_mobile_dashboard_exposes_touch_safe_draft_recovery_lane():
assert 'Discard this unfinished draft?' in html
assert '.draft-actions button { min-height:44px;' in html
assert 'createDraftInbox({ storage: localStorage' in html
assert "captureDraft.repository && !repositories.includes(captureDraft.repository)" in html
assert "captureDraft.repository && !issueCaptureRepositories.includes(captureDraft.repository)" in html
assert "Verified not posted — retry" in html
assert "payload.detail?.message" in html
assert "error.code = payload.detail?.code" in html
+58
View File
@@ -3,8 +3,10 @@ import re
import shutil
from pathlib import Path
import httpx
import pytest
from src import main
from src.frontend_bundle import build_frontend
from src.views import dashboard, runtime_bundle, service_worker
@@ -37,6 +39,62 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert changed.runtime_bytes != first.runtime_bytes
@pytest.mark.anyio
async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.headers["content-encoding"] == "gzip"
assert response.headers["vary"] == "Accept-Encoding"
assert int(response.headers["content-length"]) <= 100 * 1024
assert response.content == main.FRONTEND_BUILD.runtime_bytes
assert response.headers["cache-control"] == "public, max-age=31536000, immutable"
@pytest.mark.anyio
@pytest.mark.parametrize("accept_encoding", ["identity", "gzip;q=0, identity;q=1"])
async def test_runtime_stays_unencoded_when_the_client_declines_gzip(accept_encoding):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
f"/{main.FRONTEND_BUILD.runtime_name}",
headers={"Accept-Encoding": accept_encoding},
)
assert response.status_code == 200
assert "content-encoding" not in response.headers
assert int(response.headers["content-length"]) == len(main.FRONTEND_BUILD.runtime_bytes)
assert response.content == main.FRONTEND_BUILD.runtime_bytes
@pytest.mark.anyio
async def test_dashboard_shell_is_compressed_without_losing_response_policies():
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
page = await client.get("/", headers={"Accept-Encoding": "gzip"})
stylesheet = await client.get(
"/static/dashboard.css", headers={"Accept-Encoding": "gzip"}
)
health = await client.get("/healthz", headers={"Accept-Encoding": "gzip"})
assert page.headers["content-encoding"] == "gzip"
assert page.headers["cache-control"] == "no-cache"
assert page.headers["content-security-policy"]
assert main.FRONTEND_BUILD.runtime_name in page.text
assert stylesheet.headers["content-encoding"] == "gzip"
assert "mobile-task-dock" in stylesheet.text
assert health.json() == {"service": "stackchain-dashboard", "status": "ok"}
assert "content-encoding" not in health.headers
def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules():
build = build_frontend(FRONTEND)
+159
View File
@@ -8,6 +8,13 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
async def repository_access_from(loader, repository):
repositories = await loader()
return next(
(item for item in repositories if item.get("full_name") == repository), None
)
@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
requests = []
@@ -70,6 +77,10 @@ async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch)
}
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -352,6 +363,10 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
transport = httpx.ASGITransport(app=main.app)
@@ -373,6 +388,118 @@ async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirm
assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]
@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
calls = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.url.path, dict(request.url.params)))
if request.url.path.endswith("/user/repos"):
return httpx.Response(
200,
json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
headers={"X-Total-Count": "51"},
)
if request.url.path.endswith("/repos/stackchain/later"):
return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
page = await gitea_proxy.repo_page(page=2, limit=50)
first_page = await gitea_proxy.repos()
accessible = await gitea_proxy.repository_access("stackchain/later")
missing = await gitea_proxy.repository_access("stackchain/missing")
finally:
await gitea_proxy.stop_client()
assert page == {
"items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
"page": 2,
"total": 51,
"has_more": False,
}
assert accessible["full_name"] == "stackchain/later"
assert missing is None
assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
assert calls == [
("/api/v1/user/repos", {"page": "2", "limit": "50"}),
("/api/v1/user/repos", {"page": "1", "limit": "50"}),
("/api/v1/repos/stackchain/later", {}),
("/api/v1/repos/stackchain/missing", {}),
]
@pytest.mark.anyio
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
access_calls = []
create_calls = []
async def page(page, limit):
assert (page, limit) == (2, 50)
return {
"items": [{
"id": 51, "name": "later", "full_name": "stackchain/later",
"description": "", "html_url": "https://forge.example/stackchain/later",
"updated_at": "2026-08-09T12:00:00Z",
}],
"page": 2, "total": 51, "has_more": False,
}
async def access(repository):
access_calls.append(repository)
return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None
async def user():
return {"login": "timmy"}
async def create(repository, title, body, assignee, label_ids):
create_calls.append((repository, title, assignee))
return {
"id": 403, "number": 403, "title": title, "state": "open",
"repository": repository, "labels": [], "assignees": [assignee],
"updated_at": "2026-08-09T12:00:00Z",
"url": "https://forge.example/stackchain/later/issues/403",
}
monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repositories?page=2&limit=50")
created = await client.post(
"/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
)
assert listed.status_code == 200
assert listed.json()["items"][0]["full_name"] == "stackchain/later"
assert listed.json()["has_more"] is False
assert created.status_code == 201
assert access_calls == ["stackchain/later"]
assert create_calls == [("stackchain/later", "Later-page work", "timmy")]
def test_context_exposes_truthful_repository_pagination():
repositories = gitea_proxy.RepositoryItems(
[{
"id": 1, "name": "api", "full_name": "stackchain/api",
"description": "", "html_url": "https://forge.example/stackchain/api",
"updated_at": "2026-08-09T12:00:00Z",
}],
{"page": 1, "total": 51, "has_more": True},
)
payload = main._context_payload(
{"id": 2, "login": "timmy"}, repositories, [], []
)
assert payload["repository_pagination"] == {
"page": 1, "total": 51, "has_more": True
}
@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
calls = []
@@ -400,6 +527,10 @@ async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypa
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
@@ -448,6 +579,10 @@ async def test_create_issue_replays_one_upstream_result_for_concurrent_idempoten
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-concurrent"}
@@ -496,6 +631,10 @@ async def test_completed_issue_creation_replays_after_ledger_reconstruction(monk
)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "restart-create-201"}
@@ -544,6 +683,10 @@ async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-payload-conflict"}
@@ -585,6 +728,10 @@ async def test_create_issue_retry_recovers_result_after_the_first_request_times_
monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
@@ -627,6 +774,10 @@ async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
monkeypatch.setattr(main, "_idempotency_ledger", ledger)
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -735,6 +886,10 @@ async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(
return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
@@ -811,6 +966,10 @@ async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
monkeypatch.setattr(main.gitea_proxy, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
monkeypatch.setattr(
main.gitea_proxy, "repository_access",
lambda repository: repository_access_from(available_repos, repository),
)
monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
transport = httpx.ASGITransport(app=main.app)
+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-v59" in source
assert "stackchain-dashboard-shell-v67" 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-v59" in worker
assert "stackchain-dashboard-shell-v67" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v59" in worker
assert "stackchain-dashboard-shell-v67" in worker
+91 -3
View File
@@ -8,6 +8,39 @@ from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2}};
const calls = [];
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
openFallback: () => calls.push('fallback'),
}});
const modes = [];
modes.push(entry.open());
state.active = false; modes.push(entry.open());
state.resumable = false; modes.push(entry.open());
state.today = 0; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "resume", "start", "work"],
"calls": ["continue", "resume", "start", "fallback"],
}
def test_mobile_task_dock_routes_actions_hides_for_overlays_and_restores_focus():
@@ -70,6 +103,43 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const workLabel = {{ textContent:'' }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
dock.updateWork('continue', 3);
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('resume', 0);
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
dock.updateWork('start', 0);
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('work', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"continuing": {
"text": "Continue",
"count": "3",
"hidden": False,
"label": "Continue Today, 3 items need attention",
},
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"fallback": {"text": "Work", "label": "Work"},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -95,6 +165,24 @@ process.stdout.write(JSON.stringify({{
}
@pytest.mark.anyio
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
html = await dashboard()
assert 'id="mobile-work-label">Work</span>' in html
assert '<script src="static/mobile-work-entry.js"></script>' in html
assert "const mobileWorkEntry = createMobileWorkEntry({" in html
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "isTodayResumable: () => workSession.resumable()" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "continueToday: continueTodaySession" in html
assert "resumeToday: resumeTodaySession" in html
assert "startToday: startTodaySession" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen()" in html
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
@@ -112,12 +200,12 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: openMobileWork" in html
assert "counts.attention ? 'attention' : 'all'" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "const filter = counts.attention ? 'attention' : 'all';" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
assert "search: () => qs('#open-palette').click()" in html
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
assert "draftCount.textContent = sourceDraftCount.textContent" in html
assert "mobileTaskDock.updateAttention(counts.attention)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" in html
+419
View File
@@ -20,6 +20,87 @@ CONVERSATION = Path(__file__).parents[1] / "frontend" / "conversation.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
script = f"""
const createCardPlanning = require({json.dumps(str(CARD_PLANNING))});
function disclosure(name) {{
const listeners = {{}};
const attrs = {{}};
const summary = {{
focusCount: 0,
setAttribute(key, value) {{ attrs[key] = value; }},
focus() {{ this.focusCount += 1; }},
}};
return {{
name, open: false, dataset: {{}}, summary, attrs,
querySelector(selector) {{ return selector === 'summary' ? summary : null; }},
addEventListener(type, listener) {{ listeners[type] = listener; }},
fire(type, event = {{}}) {{ listeners[type](event); }},
}};
}}
const first = disclosure('first');
const second = disclosure('second');
const controller = createCardPlanning({{
querySelectorAll() {{ return [first, second]; }},
}});
controller.wire();
first.open = true;
first.fire('toggle');
second.open = true;
second.fire('toggle');
const escape = {{
key: 'Escape', prevented: 0, stopped: 0,
preventDefault() {{ this.prevented += 1; }},
stopPropagation() {{ this.stopped += 1; }},
}};
second.fire('keydown', escape);
process.stdout.write(JSON.stringify({{
firstOpen: first.open,
firstExpanded: first.attrs['aria-expanded'],
secondOpen: second.open,
secondExpanded: second.attrs['aria-expanded'],
secondFocus: second.summary.focusCount,
prevented: escape.prevented,
stopped: escape.stopped,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"firstOpen": False,
"firstExpanded": "false",
"secondOpen": False,
"secondExpanded": "false",
"secondFocus": 1,
"prevented": 1,
"stopped": 1,
}
@pytest.mark.anyio
async def test_my_work_cards_progressively_disclose_planning_on_phones():
html = await dashboard()
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert '<script src="static/card-planning.js"></script>' in html
assert "const cardPlanning = createCardPlanning(document);" in html
assert "cardPlanning.wire();" in html
assert (
'<details class="card-planning" data-card-planning>'
'<summary aria-expanded="false">Plan or defer</summary>'
'<div class="card-planning-actions">'
) in html
assert '.card-planning > summary { display:none;' in html
assert '.card-planning:not([open]) > .card-planning-actions { display:grid;' in html
mobile = html.index('@media (max-width: 600px)')
assert '.card-planning > summary { min-height:44px; display:flex;' in html[mobile:]
assert '.card-planning:not([open]) > .card-planning-actions { display:none;' in html[mobile:]
assert "BASE + 'static/card-planning.js'" in service_worker
def test_unassigned_issue_update_claims_once_and_becomes_today_ready():
@@ -1347,6 +1428,200 @@ process.stdout.write(JSON.stringify({{finished,active:session.active()}}));
assert json.loads(result.stdout) == {"finished": 1, "active": False}
def test_active_today_session_reopens_current_item_without_restarting_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let saves = 0;
const checkpoint = {{
read: () => null,
save: () => {{ saves += 1; return true; }},
clear: () => true,
}};
const items = [1, 2].map(number => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const progress = [];
const session = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all', checkpoint,
onOpen: item => opened.push(item.number),
onProgress: state => progress.push(state.index), onFinish: () => {{}},
}});
session.start();
session.next();
const savesBeforeReopen = saves;
const reopened = session.reopen();
process.stdout.write(JSON.stringify({{
reopened, opened, progress, savesBeforeReopen, savesAfterReopen:saves,
todayActive:session.checkpointed(),
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"reopened": True,
"opened": [1, 2, 2],
"progress": [1, 2, 2],
"savesBeforeReopen": 2,
"savesAfterReopen": 2,
"todayActive": True,
}
def test_today_session_checkpoint_resumes_current_item_after_reload():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const items = [1, 2, 3].map(number => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Item ' + number,
}}));
const opened = [];
const firstCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const first = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: firstCheckpoint,
onOpen: item => opened.push('first:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
first.start();
first.next();
const secondCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin: () => 'timmy',
}});
const resumed = buildMyWork.createWorkSession({{
getItems: () => items, getFilter: () => 'all',
checkpoint: secondCheckpoint,
onOpen: item => opened.push('resumed:' + item.number),
onProgress: () => {{}}, onFinish: () => {{}},
}});
const available = resumed.resumable();
const didResume = resumed.resume();
process.stdout.write(JSON.stringify({{
available, didResume, opened, checkpoint:JSON.parse(values.values().next().value),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"available": True,
"didResume": True,
"opened": ["first:1", "first:2", "resumed:2"],
"checkpoint": {
"version": 1,
"login": "timmy",
"identity": "issue:stackchain/dashboard:2:",
"index": 1,
},
}
def test_today_session_checkpoint_is_account_bound_and_end_clears_it():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let login = 'timmy';
const values = new Map();
const storage = {{
getItem: key => values.has(key) ? values.get(key) : null,
setItem: (key, value) => values.set(key, value),
removeItem: key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage, getLogin:() => login}});
const session = buildMyWork.createWorkSession({{
getItems: () => [{{kind:'issue',repository:'stackchain/dashboard',number:7}}],
getFilter: () => 'all', checkpoint,
onOpen: () => {{}}, onProgress: () => {{}}, onFinish: () => {{}},
}});
session.start();
login = 'alexander';
const visibleToOtherAccount = session.resumable();
const retainedForOwner = values.size;
login = 'timmy';
session.end();
process.stdout.write(JSON.stringify({{
visibleToOtherAccount, retainedForOwner, active:session.active(), stored:values.size,
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"visibleToOtherAccount": False,
"retainedForOwner": 1,
"active": False,
"stored": 0,
}
def test_resuming_an_empty_today_plan_clears_the_stale_checkpoint():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', JSON.stringify({{
version:1, login:'timmy', identity:'issue:stackchain/dashboard:7:', index:0,
}})]]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{storage,getLogin:() => 'timmy'}});
const session = buildMyWork.createWorkSession({{
getItems:() => [], getFilter:() => 'all', checkpoint,
onOpen:() => {{}}, onProgress:() => {{}}, onFinish:() => {{}},
}});
const resumed = session.resume();
process.stdout.write(JSON.stringify({{resumed, stored:values.size, available:session.resumable()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"resumed": False, "stored": 0, "available": False}
def test_today_session_keeps_working_when_checkpoint_storage_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let errors = 0;
const opened = [];
const checkpoint = buildMyWork.createWorkSessionCheckpoint({{
storage:{{
getItem:() => null,
setItem:() => {{ throw new Error('quota'); }},
removeItem:() => {{ throw new Error('quota'); }},
}},
getLogin:() => 'timmy',
onError:() => {{ errors += 1; }},
}});
const session = buildMyWork.createWorkSession({{
getItems:() => [1,2].map(number => ({{kind:'issue',repository:'stackchain/dashboard',number}})),
getFilter:() => 'all', checkpoint,
onOpen:item => opened.push(item.number), onProgress:() => {{}}, onFinish:() => {{}},
}});
session.start();
session.next();
process.stdout.write(JSON.stringify({{errors,opened,active:session.active()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"errors": 1, "opened": [1, 2], "active": True}
@pytest.mark.anyio
async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sheet():
html = await dashboard()
@@ -1361,6 +1636,23 @@ async def test_mobile_work_session_renders_touch_safe_controls_for_every_work_sh
assert 'aria-live="polite" data-work-session-progress' in html
@pytest.mark.anyio
async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
html = await dashboard()
assert 'id="resume-today-session"' in html
assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume()" in html
assert "workSession.end()" in html
assert '.resume-today-session, .end-today-session { min-height:44px;' in html
@pytest.mark.anyio
async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completion_actions():
html = await dashboard()
@@ -1376,6 +1668,74 @@ async def test_dashboard_wires_work_session_to_existing_sheet_flows_and_completi
assert opener in html
@pytest.mark.anyio
async def test_closing_issue_advances_active_session_once_and_exposes_close_and_next():
html = await dashboard()
assert "qs('#close-issue').textContent = workSession.active() ? 'Close & next' : 'Close issue';" in html
close_handler = html.split("qs('#close-issue').addEventListener('click'", 1)[1].split(
"qs('#close-pull-sheet').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in close_handler
assert "if (workSession.active()) workSession.complete();" in close_handler
assert close_handler.index("await issueController.close(selectedIssue)") < close_handler.index(
"if (workSession.active()) workSession.complete();"
)
assert close_handler.index("lastMyWork = lastMyWork.filter") < close_handler.index(
"if (workSession.active()) workSession.complete();"
)
@pytest.mark.anyio
async def test_merging_pull_advances_active_session_once_and_exposes_merge_and_next():
html = await dashboard()
assert "qs('#merge-pull').textContent = workSession.active() ? 'Merge & next' : 'Merge';" in html
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
assert "refreshMyWorkView({ reconcileSession:false });" in merge_handler
assert "const continuingSession = workSession.active();" in merge_handler
assert "if (workSession.active()) workSession.complete();" in merge_handler
assert "merged. Next work item opened." in merge_handler
assert merge_handler.index("await pullController.merge") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
)
assert merge_handler.index("lastMyWork = lastMyWork.filter") < merge_handler.index(
"if (workSession.active()) workSession.complete();"
)
@pytest.mark.anyio
async def test_pull_merge_keeps_non_session_copy_and_final_session_completion_announcement():
html = await dashboard()
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
assert "else if (!continuingSession)" in merge_handler
assert "merging.key + ' merged.'" in merge_handler
assert "else if (continuingSession" not in merge_handler
assert "onFinish: () =>" in html
assert "qs('#my-work-action-status').textContent = 'Work session complete.';" in html
@pytest.mark.anyio
async def test_failed_pull_merge_does_not_remove_or_advance_the_session():
html = await dashboard()
merge_handler = html.split("qs('#merge-pull').addEventListener('click'", 1)[1].split(
"qs('#keep-update-unread').addEventListener", 1
)[0]
success_path, failure_path = merge_handler.split("} catch (error) {", 1)
assert "lastMyWork = lastMyWork.filter" in success_path
assert "workSession.complete()" in success_path
assert "lastMyWork = lastMyWork.filter" not in failure_path
assert "workSession.complete()" not in failure_path
assert "The pull request remains in My Work; refresh and retry." in failure_path
assert "button.disabled = false;" in failure_path
def test_work_session_can_start_at_a_newly_created_item():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -1671,6 +2031,19 @@ async def test_mobile_my_work_exposes_truthful_work_pagination_control():
assert '.load-more-work { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_issue_capture_can_load_more_repositories_without_leaving_form():
html = await dashboard()
assert 'id="load-more-issue-repositories"' in html
assert '>Load more repositories<' in html
assert 'id="create-issue-repository-status" class="small" aria-live="polite"' in html
assert 'issueCapture.loadRepositoryPage(nextIssueRepositoryPage)' in html
assert 'lastContextSnapshot?.repository_pagination?.has_more === true' in html
assert 'appendIssueRepositories(payload.items)' in html
assert '.create-issue-repository-more { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_assigned_issue_sheet_exposes_touch_sized_content_editor():
html = await dashboard()
@@ -1773,6 +2146,52 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
assert output["results"][1]["number"] == 17
def test_issue_capture_repository_pages_are_single_flight_and_retryable_without_touching_draft():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
const values = new Map();
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
let calls = 0;
let release;
const capture = createIssueCapture({{
storage,
fetchJson: url => {{
calls += 1;
if (calls === 1) return Promise.reject(new Error('temporary'));
return new Promise(resolve => {{ release = () => resolve({{
items:[{{full_name:'stackchain/later'}}], page:2, total:51, has_more:false
}}); }});
}},
}});
capture.saveDraft({{repository:'stackchain/api', title:'Keep me', body:'Context', labelIds:[3]}});
capture.loadRepositoryPage(2).catch(() => {{
const first = capture.loadRepositoryPage(2);
const duplicate = capture.loadRepositoryPage(2);
release();
Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.stringify({{
calls, results, draft:capture.loadDraft()
}})));
}});
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output["calls"] == 2
assert output["results"] == [
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
{"items": [{"full_name": "stackchain/later"}], "page": 2, "total": 51, "has_more": False},
]
assert output["draft"] == {
"repository": "stackchain/api", "title": "Keep me", "body": "Context", "labelIds": [3]
}
def test_issue_capture_reuses_its_persisted_idempotency_key_after_reload():
script = f"""
const createIssueCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
+69 -1
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
PLAN_TODAY = Path(__file__).parents[1] / "frontend" / "plan-today.js"
PLAN_TODAY_PREVIEW = Path(__file__).parents[1] / "frontend" / "plan-today-preview.js"
SERVICE_WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
@@ -78,6 +79,53 @@ process.stdout.write(JSON.stringify({{result, calls, snapshot:planner.snapshot()
}
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const createPlanTodayPreview = require({json.dumps(str(PLAN_TODAY_PREVIEW))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const events = [];
let scroll = 318;
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
planner.open([item(1)], [item(1), item(2)]);
const preview = createPlanTodayPreview({{
planner,
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
getScroll: () => scroll,
setScroll: value => {{ scroll = value; }},
onOpen: value => events.push(['open', value.number]),
onClose: (value, trigger) => events.push(['close', value.number, trigger]),
}});
preview.open(item(2), 'preview-2');
scroll = 0;
const first = preview.close({{add:true}});
preview.open(item(2), 'preview-2');
scroll = 0;
const second = preview.close({{add:true}});
process.stdout.write(JSON.stringify({{first, second, scroll, events, planner:planner.snapshot()}}));
"""
assert run_node(script) == {
"first": "added",
"second": "already-added",
"scroll": 318,
"events": [
["open", 2],
["close", 2, "preview-2"],
["open", 2],
["close", 2, "preview-2"],
],
"planner": {
"open": True,
"ids": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"],
"count": 2,
"limit": 5,
},
}
@pytest.mark.anyio
async def test_mobile_dashboard_wires_focused_plan_today_sheet():
html = await dashboard()
@@ -96,10 +144,30 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert "padding-bottom:calc(12px + env(safe-area-inset-bottom))" in html
assert ".plan-today-item { grid-template-columns:1fr; }" in html
assert ".plan-today-item-actions { display:grid; grid-template-columns:repeat(3,1fr);" in html
assert ".plan-preview-actions { position:fixed; z-index:76;" in html
assert '<script src="static/plan-today-preview.js"></script>' in html
assert 'data-plan-preview="' in html
assert 'id="add-plan-preview"' in html
assert "taskOverlayHistory.open('plan-today-preview')" in html
assert "planTodayPreview.close({ add:true })" in html
assert "Preview unavailable offline" in html
@pytest.mark.anyio
async def test_plan_today_wires_cancel_back_and_success_through_overlay_history():
html = await dashboard()
assert "taskOverlayHistory.open('plan-today')" in html
assert "previous === 'plan-today' && kind !== 'plan-today'" in html
assert "kind === 'plan-today' && previous !== 'plan-today'" in html
assert "openPlanToday(planTodayTrigger, false)" in html
assert "closePlanToday(false)" in html
assert "if (result === 'saved') taskOverlayHistory.leave();" in html
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+66 -8
View File
@@ -14,10 +14,23 @@ const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, outboxPurges: 0, notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
new Response(JSON.stringify({{expires_at: Math.floor(Date.now() / 1000) + 3600}})),
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
match: async request => state.cachedBody === null ? null : new Response(state.cachedBody),
put: async (request, response) => {{ state.puts.push(String(request.url || request)); }},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
return state.cachedBody === null ? null : new Response(state.cachedBody);
}},
put: async (request, response) => {{
const key = String(request.url || request);
state.puts.push(key);
storedResponses.set(key, response.clone());
}},
}};
const context = {{
URL, Request, Response, Headers, AbortController,
@@ -105,10 +118,19 @@ async function dispatchNotificationClick(route) {{
return json.loads(completed.stdout)
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -116,14 +138,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -132,21 +154,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -309,6 +331,13 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
assert "batch: withSessionCsrf" in source
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/queue-today.js'" in source
def test_install_precaches_complete_subpath_scoped_app_shell():
result = run_worker_scenario(
"""
@@ -340,8 +369,10 @@ 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/card-planning.js",
"/dashboard/static/today-work.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/later-work.js",
@@ -353,12 +384,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
"/dashboard/static/create-and-start.js",
"/dashboard/static/assign-and-start.js",
"/dashboard/static/queue-today.js",
"/dashboard/static/pull-sheet.js",
"/dashboard/static/review-sheet.js",
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-search-viewport.js",
@@ -398,6 +432,30 @@ def test_offline_navigation_returns_cached_shell_for_share_target_url():
]
def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_shell():
result = run_worker_scenario(
"""
await dispatchMessage({type:'stackchain-session-lease', expiresAt:1});
state.failFetch = true;
state.cachedBody = 'private cached dashboard';
const response = await dispatch('fetch', {
method:'GET', mode:'navigate', url:'https://forge.example/dashboard/',
});
process.stdout.write(JSON.stringify({
status: response.status,
body: await response.text(),
state,
}));
"""
)
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
def test_stalled_navigation_is_aborted_and_returns_cached_shell_within_deadline():
result = run_worker_scenario(
"""
+83
View File
@@ -117,6 +117,89 @@ process.stdout.write(JSON.stringify({{left, state:history.state, current:control
}
def test_plan_today_uses_one_history_layer_and_can_leave_after_save():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
replaceState(state) {{ stack[cursor] = state; }},
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
const opened = controller.open('plan-today');
const repeated = controller.open('plan-today');
const left = controller.leave();
process.stdout.write(JSON.stringify({{
opened, repeated, left, stack, cursor, current:controller.current(), changes,
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"repeated": True,
"left": True,
"stack": [
{"page": "dashboard"},
{"page": "dashboard"},
],
"cursor": 1,
"current": None,
"changes": [["plan-today", None], [None, "plan-today"]],
}
def test_plan_today_preview_is_a_nested_browser_history_layer():
script = f"""
const createTaskOverlayHistory = require({json.dumps(str(OVERLAY_HISTORY))});
const listeners = {{}};
const changes = [];
const stack = [{{ page:'dashboard' }}];
let cursor = 0;
const history = {{
get state() {{ return stack[cursor]; }},
pushState(state) {{ stack.splice(cursor + 1); stack.push(state); cursor += 1; }},
back() {{ cursor -= 1; listeners.popstate({{state:stack[cursor]}}); }},
}};
const controller = createTaskOverlayHistory({{
history,
eventTarget: {{ addEventListener(name, callback) {{ listeners[name] = callback; }} }},
onChange(kind, previous) {{ changes.push([kind, previous]); }},
}});
controller.start();
controller.open('plan-today');
const previewed = controller.open('plan-today-preview');
controller.close();
process.stdout.write(JSON.stringify({{previewed, cursor, current:controller.current(), changes}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"previewed": True,
"cursor": 1,
"current": "plan-today",
"changes": [
["plan-today", None],
["plan-today-preview", "plan-today"],
["plan-today", "plan-today-preview"],
],
}
def test_dashboard_routes_mobile_task_overlays_through_browser_history():
html = dashboard_bundle_text()
+1 -1
View File
@@ -86,7 +86,7 @@ sync.enqueue('add', 'issue:r:1:');
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v59" in source
assert "stackchain-dashboard-shell-v67" in source
assert "BASE + 'static/today-sync.js'" in source
+2 -1
View File
@@ -161,7 +161,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert 'data-work-filter="today"' in html
assert 'data-work-count="today"' in html
assert "const todayWork = createTodayWork({" in html
assert "const filter = todayMyWork.length ? 'today' : (counts.attention ? 'attention' : 'all');" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "startToday: startTodaySession" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html