Compare commits

...
Author SHA1 Message Date
timmy 995d8f8527 feat: find work within active queues (Closes #667)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:56:42 +00:00
timmy fabd6123f8 Create follow-up and continue the Updates inbox (#666)
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
Closes #665
2026-08-12 17:13:59 +00:00
timmy 2553e7121b feat: create follow-up and continue updates (Closes #665)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:10:16 +00:00
timmy 681e10c7db Merge pull request 'Create follow-up work from unread updates' (#664) from timmy/663-update-follow-up into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 16:35:34 +00:00
timmy d96a77c5d5 feat: create follow-up work from updates (Closes #663)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:32:50 +00:00
timmy 97eb928187 Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 16:05:43 +00:00
timmy 80a4096118 feat: batch-plan mobile work queues (Closes #661)
CI / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:03:04 +00:00
timmy f04a547e19 Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates into main
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 15:14:22 +00:00
timmy ea3712ca3e feat: defer selected updates as unread batch (Closes #659)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 15:11:51 +00:00
timmy 7421a8f62d Merge pull request 'Make every mobile Work queue addressable and Back-safe' (#658) from timmy/657-addressable-mobile-work-queues into main
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
2026-08-12 14:31:25 +00:00
timmy 982c2a1fb7 feat: make mobile work queues addressable (Closes #657)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 14:29:07 +00:00
timmy 3e7d6f7111 Merge pull request 'Isolate unexpected Web Push device failures' (#656) from timmy/655-isolate-push-device-failures into main
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
2026-08-12 13:55:56 +00:00
timmy 32394fcd8f fix: isolate Web Push device failures (Closes #655)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:53:38 +00:00
timmy ea3194cdca Merge pull request 'Restrict Web Push delivery to public endpoints' (#654) from timmy/653-push-endpoint-egress-policy into main
CI / lint (push) Successful in 1m37s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Merge pull request 'Restrict Web Push delivery to public endpoints' (#654)
2026-08-12 13:32:32 +00:00
timmy 85043b4af6 fix: restrict Web Push endpoint egress (Closes #653)
CI / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:29:50 +00:00
timmy dc202bde11 Merge pull request 'Consolidate mobile My Work actions into the thumb dock' (#652) from timmy/651-mobile-command-center into main
CI / lint (push) Successful in 1m54s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 13:04:37 +00:00
timmy c4ba78c8cb feat: consolidate mobile work commands (Closes #651)
CI / lint (pull_request) Successful in 1m51s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:01:41 +00:00
timmy e35d7dea35 Merge pull request 'Undo accidental mobile update acknowledgements' (#650) from timmy/649-undo-update-acknowledgement into main
CI / lint (push) Successful in 1m33s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 12:37:12 +00:00
timmy b81d695585 feat: undo accidental update acknowledgements (Closes #649)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 12:34:55 +00:00
timmy 75da76c6f5 Merge pull request 'Open Web Push digests in the mobile Updates inbox' (#648) from timmy/647-push-updates-inbox into main
CI / lint (push) Successful in 1m43s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 12:04:40 +00:00
timmy f78d69fba5 fix: open push digests in Updates inbox (Closes #647)
CI / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:58:11 +00:00
timmy 8ce64d2aa7 Merge pull request 'Launch work directly from mobile queues' (#646) from timmy/645-mobile-queue-launcher into main
CI / lint (push) Successful in 1m39s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
2026-08-12 11:28:51 +00:00
timmy 48f75cff05 feat: launch work directly from mobile queues (Closes #645)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
timmy 58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 10:56:08 +00:00
timmy 54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
CI / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
timmy a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
CI / lint (push) Successful in 1m32s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 10:29:03 +00:00
timmy c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
timmy 5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 09:54:56 +00:00
timmy 95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
timmy c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
CI / lint (push) Successful in 1m40s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
Closes #637
2026-08-12 09:28:47 +00:00
timmy bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
timmy 9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
2026-08-12 08:58:11 +00:00
timmy a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:55:52 +00:00
timmy 528d721cd3 Merge pull request 'Build my Today from ranked ready work' (#634) from timmy/633-build-my-today into main
CI / lint (push) Successful in 1m36s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 08:38:49 +00:00
timmy a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
CI / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 08:28:03 +00:00
timmy 28a2c9faf1 Merge pull request 'Make Draft filing admission atomic and retry-safe' (#632) from timmy/631-atomic-draft-admission into main
CI / lint (push) Successful in 1m29s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 07:57:53 +00:00
timmy b8befc2041 fix: make Draft admission retry-safe (Closes #631)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:54:12 +00:00
timmy d23c0c894b Merge pull request 'File mobile Drafts in one sequential session' (#630) from timmy/629-sequential-draft-filing into main
CI / lint (push) Successful in 1m31s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
2026-08-12 07:20:10 +00:00
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / lint (pull_request) Successful in 1m29s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 07:18:02 +00:00
timmy 1079ec6561 Merge pull request 'Unify mobile work queues in a stable dock switcher' (#628) from timmy/627-mobile-queue-switcher into main
CI / lint (push) Successful in 1m41s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 06:29:46 +00:00
timmy 8d142c808b feat: unify mobile work queues (Closes #627)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 06:27:00 +00:00
timmy 776ae70019 Merge pull request 'Preserve mobile Drafts when attachment cleanup fails' (#626) from timmy/625-draft-mutation-rollback into main
CI / lint (push) Successful in 1m22s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:56:46 +00:00
timmy 285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
CI / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:54:19 +00:00
timmy 89d153d6a5 Merge pull request 'Prevent silent mobile Draft loss at capacity' (#624) from timmy/623-draft-capacity-guard into main
CI / lint (push) Successful in 1m34s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 05:38:48 +00:00
timmy fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 05:36:32 +00:00
timmy 14ba1bcf14 Merge pull request 'Complete the mobile Save to Drafts handoff' (#622) from timmy/621-mobile-draft-handoff into main
CI / lint (push) Successful in 1m28s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:57:56 +00:00
timmy a68639668c feat: complete mobile Draft handoff (Closes #621)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:54:45 +00:00
timmy 1f0e9fb59d Merge pull request 'Make PWA updates resilient with progressive feature caching' (#620) from timmy/619-progressive-feature-caching into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:26:38 +00:00
timmy 065275b4ef feat: progressively cache optional PWA features (Closes #619)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 04:24:18 +00:00
timmy 90f1c48565 Merge pull request 'Purge capture-first screenshot Drafts at every private-data boundary' (#618) from timmy/617-purge-capture-drafts into main
CI / lint (push) Successful in 1m26s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 04:02:55 +00:00
timmy ae07dd185c fix: purge capture Draft screenshots (Closes #617)
CI / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:58:10 +00:00
rockachopa f43f776479 Merge pull request 'Preserve screenshots in capture-first mobile Drafts' (#616) from timmy/615-capture-first-screenshots into main
CI / lint (push) Successful in 1m25s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
2026-08-12 03:35:30 +00:00
timmy 48e4344f28 feat: preserve capture-first screenshots in Drafts (Closes #615)
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 03:33:19 +00:00
rockachopa 269916486c Merge pull request 'Make mobile New a capture-first flow' (#614) from timmy/613-mobile-capture-first into main
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 02:56:56 +00:00
timmy 07235d3870 feat: make mobile New capture-first (Closes #613)
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:54:44 +00:00
rockachopa 8c32a9dd81 Merge pull request 'Start an empty Today plan from the mobile Work dock' (#612) from timmy/611-mobile-work-plan-today into main
CI / lint (push) Successful in 1m27s
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
2026-08-12 02:28:30 +00:00
timmy 9edd3f6441 feat: start empty Today planning from mobile dock (Closes #611)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:24:10 +00:00
59 changed files with 3374 additions and 200 deletions

No files matched your search

+19 -8
View File
@@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
either stage. The mobile **New issue**
sheet accepts the same image formats and stores the screenshot with its account-bound
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
queued base64 screenshot payloads remain readable and are converted only at delivery time.
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
preview when the operator later chooses a repository. The source Draft remains available until its
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
delivery time.
Delivery creates the issue exactly once, then uploads and comments with
the image; after a partial failure, retry resumes with the confirmed issue instead of
creating a duplicate.
@@ -196,6 +199,9 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
@@ -268,7 +274,12 @@ session linked to the credential is revoked atomically; unrelated credentials an
sessions remain valid. Removing the current device's passkey keeps its current
session active, so the sheet warns that the recovery token will be required after
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
normally keeps the passkey available for the next sign-in.
normally keeps the passkey available for the next sign-in. Passkey assertion counters
advance with an atomic compare-and-swap across workers: stale or equal nonzero counters
fail before a session or authorization grant is issued, while counterless authenticators
remain compatible. The bounded **Passkey counter anomaly** Security activity entry names
only the device label and attempted action; repeated alerts should prompt removal and
re-enrollment of that passkey.
High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require a passkey assertion or the
+15
View File
@@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
return transact;
}
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
return {
put: (id, value) => transact(records => records.put({id, ...value})),
get: id => transact(async records => {
const value = await records.get(id);
if (!value) return null;
const {id: _id, ...attachment} = value;
return attachment;
}),
delete: id => transact(records => records.delete(id)),
};
}
function createIssueSyncStore({
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
createToken = () => globalThis.crypto?.randomUUID?.() ||
@@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
if (typeof globalThis !== 'undefined') {
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
globalThis.createIssueSyncStore = createIssueSyncStore;
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
}
+38
View File
@@ -22,6 +22,7 @@ function normalizeSharedContent(value = {}) {
function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOperationId }) {
const storageKey = 'stackchain.issue-capture.v1';
const sharedStorageKey = 'stackchain.issue-share.v1';
const followUpStorageKey = 'stackchain.issue-follow-up.v1';
let pending = null;
let duplicateRequest = 0;
let repositorySearchRequest = 0;
@@ -126,6 +127,42 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
return {status: 'ready'};
}
function pendingFollowUp() {
try {
const parsed = JSON.parse(storage.getItem(followUpStorageKey) || 'null');
if (!parsed || typeof parsed !== 'object') return null;
const repository = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(parsed.repository || '')) ? String(parsed.repository) : '';
const title = String(parsed.title || '').trim().slice(0, 240);
const body = String(parsed.body || '').trim().slice(0, 9500);
return title || body ? {repository, title, body, labelIds: []} : null;
} catch (_error) { return null; }
}
function acceptFollowUp() {
const followUp = pendingFollowUp();
if (!followUp) return loadDraft();
const accepted = saveDraft(followUp);
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* Accepted content is already persisted as the issue draft. */ }
return accepted;
}
function discardFollowUp() {
try { storage.removeItem(followUpStorageKey); }
catch (_error) { /* The existing capture remains authoritative. */ }
}
function stageFollowUp(value) {
const followUp = {repository: String(value?.repository || ''), title: String(value?.title || ''), body: String(value?.body || '')};
if (!followUp.title && !followUp.body) return {status: 'empty'};
try { storage.setItem(followUpStorageKey, JSON.stringify(followUp)); }
catch (_error) { /* The in-page flow can still continue. */ }
const existing = loadDraft();
if (existing.title || existing.body) return {status: 'conflict'};
acceptFollowUp();
return {status: 'ready'};
}
function loadLabels(repository) {
const encoded = String(repository || '').split('/').map(encodeURIComponent).join('/');
const priorities = new Set(['p0', 'priority-high', 'critical']);
@@ -254,6 +291,7 @@ function createIssueCapture({ fetchJson, storage, createOperationId = newIssueOp
searchRepositories, findDuplicates,
needsDuplicateAcknowledgement, acknowledgeDuplicates, submit,
stageSharedContent, pendingSharedContent, acceptSharedContent, discardSharedContent,
stageFollowUp, pendingFollowUp, acceptFollowUp, discardFollowUp,
};
}
+59 -7
View File
@@ -10,6 +10,17 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-capacity-actions button { min-height:44px; width:100%; }
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
#sign-out-all { min-height:44px; }
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.active-devices-sheet[hidden] { display:none; }
@@ -126,6 +137,15 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
.plan-today-available span { display:flex; align-items:center; gap:6px; }
.plan-today-available input, .plan-today-estimate { box-sizing:border-box; min-height:44px; width:108px; }
.plan-today-build { min-height:44px; width:100%; font-weight:700; }
.plan-today-build-status { min-height:1.4em; margin-top:6px; color:#bfdbfe; }
.plan-today-estimates { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
.plan-today-estimates h3, .plan-today-estimates p { margin-top:0; }
.plan-today-estimates input { min-height:44px; width:108px; box-sizing:border-box; }
.plan-today-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:10px; padding:8px 0; border-top:1px solid #29496d; }
.plan-today-estimate-row label { display:flex; align-items:center; gap:6px; }
.plan-today-skipped { display:grid; gap:6px; margin:8px 0; }
.plan-today-skipped-item { padding:8px 10px; border-left:3px solid #b45309; background:#261a13; overflow-wrap:anywhere; }
.plan-today-estimate-wrap { display:flex; align-items:center; gap:6px; margin-top:8px; }
.plan-today-error { min-height:1.4em; color:#fca5a5; }
.discard-recap-replan { min-height:44px; margin-bottom:8px; }
@@ -188,8 +208,16 @@ textarea { resize: vertical; min-height: 120px; }
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
.queue-finder { display:grid; gap:6px; margin:10px 0; }
.queue-finder > label { font-weight:700; }
.queue-finder-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.queue-finder input, .queue-finder button { min-height:44px; box-sizing:border-box; }
.queue-finder input { min-width:0; width:100%; padding:8px 10px; border:1px solid #31577f; border-radius:10px; background:#08111f; color:var(--text); font:inherit; }
.queue-finder button { padding-inline:14px; }
#search-older-work { width:100%; }
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
.draft-actions button { min-height:44px; width:100%; }
@@ -202,6 +230,10 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); }
.create-issue-actions button { min-height:44px; max-width:100%; width:100%; }
.create-issue-actions #create-issue-status { grid-column:1/-1; }
.draft-filing-session { position:sticky; top:0; z-index:2; display:grid; gap:6px; margin-bottom:12px; padding:10px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:12px; background:rgba(11,21,38,.98); }
.draft-filing-session[hidden] { display:none; }
.draft-filing-session-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.draft-filing-session-actions button { min-height:44px; width:100%; }
.my-work-card { min-height: 44px; display:grid; gap:8px; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
.my-work-card-main { display:block; width:100%; color:var(--text); text-align:left; font:inherit; background:transparent; border:0; padding:0; }
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
@@ -209,8 +241,10 @@ textarea { resize: vertical; min-height: 120px; }
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.update-selection-controls button { min-height:44px; }
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
.update-selector { min-height:44px; }
.work-selector { min-height:44px; }
.update-selector, .work-selector { display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
.update-selector input, .work-selector input { width:22px; height:22px; flex:0 0 auto; }
.my-work-card.selection-active { border-color:#31577f; }
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
@@ -243,6 +277,9 @@ textarea { resize: vertical; min-height: 120px; }
.retry-work-route[hidden] { display:none; }
.my-work-bulk { position:sticky; bottom:0; z-index:4; margin:10px -4px -12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.my-work-bulk button { min-height:44px; width:100%; }
.my-work-bulk-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.my-work-bulk-actions button { min-height:44px; width:100%; }
.my-work-bulk-actions #bulk-mark-read { grid-column:1 / -1; }
.my-work[data-stale="true"] { border-color:#fcd34d; }
.review-sheet { position:fixed; inset:0; z-index:50; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.review-sheet.open { display:flex; }
@@ -449,8 +486,12 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-sheet.open { display:flex; }
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
.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-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
.create-issue-form { display:grid; gap:12px; }
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
.create-issue-capture-actions[hidden] { display:none; }
.create-issue-filing { display:grid; gap:12px; min-width:0; }
.create-issue-filing[hidden] { display:none; }
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
.create-issue-repository-more { min-height:44px; width:100%; }
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
@@ -531,11 +572,11 @@ textarea { resize: vertical; min-height: 120px; }
.my-work { margin:0; }
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.my-work-actions { display:none; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
@@ -567,14 +608,25 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.review-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
.composer-keyboard-active .create-issue-form,
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.mobile-queue-panel h2 { margin:0; }
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
.mobile-queue-list small { color:var(--muted); }
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+390 -76
View File
@@ -9,6 +9,7 @@
{ panel:qs('#issue-sheet .issue-sheet-panel'), workspace:qs('.issue-comment-composer'), composer:qs('#issue-comment'), submit:qs('#send-issue-comment'), status:qs('#issue-comment-status') },
{ panel:qs('#pull-sheet .pull-sheet-panel'), workspace:qs('.pull-comment-composer'), composer:qs('#pull-comment'), submit:qs('#send-pull-comment'), status:qs('#pull-comment-status') },
{ panel:qs('#update-sheet .update-sheet-panel'), workspace:qs('.update-reply'), composer:qs('#update-reply'), submit:qs('#send-update-reply'), status:qs('#update-reply-status') },
{ panel:qs('#review-sheet .review-sheet-panel'), workspace:qs('#review-inline-composer'), composer:qs('#review-inline-body'), submit:qs('#save-inline-comment'), status:qs('#review-submit-status') },
{ panel:qs('.create-issue-panel'), workspace:qs('#create-issue-form'), focusWithin:true },
],
});
@@ -36,7 +37,7 @@
try {
localStorage.setItem(PANEL_STATE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Could not persist panel state', e);
console.warn('Panel state save failed', e);
}
});
});
@@ -55,11 +56,11 @@
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
function openMobileAttention() {
if (workSession.checkpointed()) timer.beginAttention();
function selectMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
timerView.render();
qs('[data-work-filter="attention"]').click();
qs('[data-work-filter="' + name + '"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
@@ -67,25 +68,41 @@
isTodayActive: () => workSession.checkpointed(),
isTodayResumable: () => workSession.resumable(),
getTodayCount: () => todayMyWork.length,
getEligibleCount: () => activeMyWork.length,
continueToday: continueTodaySession,
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
openFallback: openMobileWorkFallback,
});
const mobileQueueLauncher = createMobileQueueLauncher({
openToday: () => mobileWorkEntry.open(),
selectFilter: selectMobileQueue,
firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit'),
announce: message => { qs('#my-work-action-status').textContent = message; },
});
const mobileTaskDock = createMobileTaskDock({
nav: qs('#mobile-task-dock'),
sessionHud: qs('[data-mobile-today-hud]'),
buttons: mobileTaskButtons,
workLabel: qs('#mobile-work-label'),
attentionBadge: qs('#mobile-attention-count'),
queueSheet: qs('#mobile-queue-sheet'),
queueClose: qs('#close-mobile-queues'),
queueRows: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
),
queueCounts: Object.fromEntries(
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
),
queueBadge: qs('#mobile-queue-count'),
onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name),
overlays: mobileTaskOverlays,
actions: {
work: () => mobileWorkEntry.open(),
attention: openMobileAttention,
find: () => qs('#find-work').click(),
new: () => qs('#new-issue').click(),
search: () => qs('#open-palette').click(),
drafts: () => qs('[data-work-filter="draft"]').click(),
queues: () => {},
},
observe(callback, overlays) {
const observer = new MutationObserver(callback);
@@ -94,20 +111,13 @@
},
});
mobileTaskDock.start();
const sourceDraftCount = qs('[data-work-count="draft"]');
const draftCount = qs('#mobile-draft-count');
function syncMobileDraftCount() {
draftCount.textContent = sourceDraftCount.textContent;
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
}
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
syncMobileDraftCount();
let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
const WORK_MILESTONE_KEY = 'stackchain.my-work-milestone.v1';
let selectedWorkFilter = 'all';
let selectedWorkMilestone = 'all';
let queueFindQuery = '';
let savedWorkFilter = null;
let launchFilterResolved = false;
try {
@@ -120,7 +130,7 @@
const savedMilestone = sessionStorage.getItem(WORK_MILESTONE_KEY);
if (savedMilestone) selectedWorkMilestone = savedMilestone;
} catch (e) {
console.warn('Could not restore My Work filter', e);
console.warn('Filter restore failed', e);
}
let lastMyWork = [];
let lastDrafts = [];
@@ -133,6 +143,8 @@
let reviewTrigger = null;
let offlineReview = false;
let selectedUpdate = null;
let selectedUpdateDetail = null;
let followUpSourceUpdate = null;
let updateTrigger = null;
let selectedIssue = null;
let selectedIssueOffline = false;
@@ -398,11 +410,21 @@
loadMilestones: item => issueController.loadMilestones(item),
});
let issueCapture = null;
let updateFollowUp = null;
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
const unfiledCaptures = createUnfiledCaptures({
storage: localStorage,
attachmentStore: unfiledAttachmentStore,
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
getCurrentLogin: () => activeFlushLogin,
});
const dFS = createDraftFilingSession({list:()=>unfiledCaptures.list().filter(item=>!item.quarantined)});
dFS.attach(qs, {
captures:unfiledCaptures, issueCapture, attachment:createIssueAttachmentController,
getLogin:()=>activeFlushLogin, setResumedId:id=>{ rUC = id; },
openSheet:openCreateIssueSheet, setFilingMode:setIssueFilingMode,
});
let rUC = '';
let backgroundIssueSync = null;
if ('indexedDB' in window) {
const backgroundIssueStore = createIssueSyncStore();
@@ -478,6 +500,7 @@
}, () => {
if (!issueCapture) {
issueCapture = createIssueCapture({ fetchJson: fetchReviewJson, storage: localStorage });
updateFollowUp = createUpdateFollowUp({ storage:localStorage, getLogin:()=>confirmedOwnerLogin });
}
if (!sharedLaunchHandled && Object.values(sharedLaunch).some(Boolean)) {
sharedLaunchState = issueCapture.stageSharedContent(sharedLaunch);
@@ -684,6 +707,7 @@
renderMyWork();
},
});
const workSelection = createWorkSelection({ limit: 50, onChange: () => renderMyWork() });
const notificationPager = createNotificationPager({
load: fetchNotificationPage,
onNotifications: items => {
@@ -768,6 +792,15 @@
if (state.message) qs('#update-sheet-status').textContent = state.message;
},
});
const notificationUndo = createDashboardNotificationUndo({
restore: requestNotificationUnread,
getItems: () => lastMyWork,
setItems: items => { lastMyWork = items; },
getNotifications: () => lastNotifications,
setNotifications: items => { lastNotifications = items; },
refresh: refreshMyWorkView,
select: qs,
});
const notificationReader = createNotificationReader({
load: fetchNotificationDetail,
loadConversation: fetchNotificationConversation,
@@ -780,6 +813,7 @@
updateReplyAttachmentController.clear();
}
selectedUpdate = item;
selectedUpdateDetail = null;
updateMentions.dismiss();
qs('#update-sheet').classList.add('open');
qs('#update-sheet-key').textContent = item.key || '';
@@ -798,17 +832,20 @@
qs('#acknowledge-update-next').hidden = true;
qs('#update-ownership-action').hidden = true;
qs('#update-ownership-start').hidden = true;
qs('#create-update-follow-up').hidden = true;
qs('#retry-update-load').hidden = true;
setOfflineUpdateControls(false);
qs('#keep-update-unread').focus();
},
onDetail: async detail => {
selectedUpdateDetail = detail;
qs('#update-sheet-title').textContent = detail.title || 'Unread update';
qs('#update-subject-type').textContent = detail.subject_type || 'Update';
qs('#update-subject-state').textContent = detail.state || '';
qs('#update-subject-body').innerHTML = renderMarkdown(detail.subject_body || 'No subject context was provided.');
qs('#open-update-gitea').href = detail.url || selectedUpdate?.url || '#';
qs('#acknowledge-update-next').hidden = !detail.acknowledge_supported;
qs('#create-update-follow-up').hidden = !['Issue', 'Pull'].includes(detail.subject_type);
if (offlineWorkMode) {
setOfflineUpdateControls(true);
} else {
@@ -909,6 +946,7 @@
else if (item.kind === 'issue') openIssueSheet(item, issueTrigger);
else if (item.kind === 'pull') openPullSheet(item, pullTrigger);
},
onQueue: openWorkQueueRoute,
onClose: () => {
if (selectedIssue) closeIssueSheet(false);
if (selectedPull) closePullSheet(false);
@@ -931,7 +969,7 @@
qs('#retry-work-route').addEventListener('click', () => workRoute.sync());
function closeOpenWorkSheets() {
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#update-sheet .update-sheet-panel']
['#issue-sheet .issue-sheet-panel', '#pull-sheet .pull-sheet-panel', '#review-sheet .review-sheet-panel', '#update-sheet .update-sheet-panel']
.forEach(selector => mobileComposerViewport.close(qs(selector)));
['#issue-sheet', '#pull-sheet', '#review-sheet', '#update-sheet'].forEach(selector =>
qs(selector).classList.remove('open')
@@ -1383,6 +1421,24 @@
formatPlanMinutes(state.planned_minutes) + ' · ' + capacityText +
(state.unestimated_count ? ' · ' + state.unestimated_count + ' unestimated' : '');
qs('#plan-today-available').value = state.capacity_minutes || '';
const build = state.build;
qs('#plan-today-build-status').textContent = build ?
('Built ' + build.selected.length + ' ready ' + (build.selected.length === 1 ? 'item' : 'items') +
(build.skipped.length ? ' · Skipped ' + build.skipped.length + ' blocked or unverified' : '') +
(build.needs_estimate.length ? ' · ' + build.needs_estimate.length + ' need estimates' : '')) : '';
const estimateSection = qs('#plan-today-estimates');
estimateSection.hidden = !build?.needs_estimate.length;
qs('#plan-today-estimate-list').innerHTML = (build?.needs_estimate || []).map(id => {
const item = planToday.item(id);
const key = escapeHtml(item?.key || ((item?.repository || '') + '#' + (item?.number || '')));
return '<div class="plan-today-estimate-row"><div><span class="small">' + key + '</span><strong class="my-work-card-title">' +
escapeHtml(item?.title || 'Untitled work') + '</strong></div><label class="small"><input type="number" inputmode="numeric" min="5" max="1440" step="5" data-plan-missing-estimate="' +
escAttr(id) + '" aria-label="Estimate for ' + escAttr(item?.title || key) + ' in minutes" /> min</label></div>';
}).join('');
qs('#plan-today-skipped').innerHTML = (build?.skipped || []).map(entry =>
'<div class="small plan-today-skipped-item"><strong>' + escapeHtml(planToday.item(entry.id)?.title || entry.id) +
'</strong> · ' + escapeHtml(entry.reason) + '</div>'
).join('');
qs('#plan-today-list').innerHTML = state.ids.length ? state.ids.map((id, index) =>
planTodayItemMarkup(planToday.item(id), true, index)
).join('') : '<p class="muted">No work selected yet.</p>';
@@ -1418,6 +1474,17 @@
planToday.setEstimate(input.dataset.planEstimate, Number(input.value));
renderPlanToday();
}));
document.querySelectorAll('[data-plan-missing-estimate]').forEach(input => input.addEventListener('change', () => {
const id = input.dataset.planMissingEstimate;
if (!input.checkValidity() || !planToday.setEstimate(id, Number(input.value))) {
qs('#plan-today-error').textContent = 'Enter an estimate from 5 minutes to 24 hours.';
input.focus();
return;
}
qs('#plan-today-error').textContent = '';
renderPlanToday();
document.querySelector('[data-plan-missing-estimate]')?.focus();
}));
document.querySelectorAll('[data-plan-recommendation]').forEach(button => button.addEventListener('click', () => {
planToday.applyRecommendation(button.dataset.planRecommendation);
renderPlanToday();
@@ -1426,6 +1493,9 @@
}
function closePlanToday(navigate = true) {
planTodayReadiness.cancel();
qs('#build-today-plan').disabled = false;
qs('#plan-today-build-status').textContent = '';
if (navigate) {
taskOverlayHistory.close();
return;
@@ -1474,6 +1544,34 @@
},
});
const planTodayReadiness = createPlanTodayReadiness({
concurrency:3,
identity:item => todayWork.identity(item),
inspect:async item => {
if (item.kind !== 'issue') return { status:'ready' };
const detail = await inspectTodayDependencies(item);
if (!detail.available) return { status:'unverified' };
if (detail.dependencies.length) return {
status:'blocked', reason:detail.dependencies.length + ' open ' +
(detail.dependencies.length === 1 ? 'dependency' : 'dependencies'),
};
return { status:'ready' };
},
});
async function buildTodayPlan() {
const button = qs('#build-today-plan');
const state = planToday.snapshot();
const candidates = state.ids.map(id => planToday.item(id)).concat(planToday.candidates());
button.disabled = true;
qs('#plan-today-build-status').textContent = 'Checking readiness and fitting your highest-ranked work…';
const checks = await planTodayReadiness.run(candidates);
if (!checks) return;
planToday.buildRecommendation(checks);
button.disabled = false;
renderPlanToday();
}
function canPreviewPlanItem(item) {
if (!item || !offlineWorkMode) return Boolean(item);
const login = planningOwnerLogin || confirmedOwnerLogin ||
@@ -1938,7 +2036,8 @@
function listDrafts() {
const unfiled = unfiledCaptures.list().map(item => ({
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
updated_at:item.savedAt, quarantined:item.quarantined,
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
@@ -1983,6 +2082,7 @@
const element = qs('[data-work-count="' + filter + '"]');
if (element) element.textContent = count;
});
mobileTaskDock.updateQueues(counts);
mobileTaskDock.updateWork(mobileWorkEntry.mode());
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
@@ -2024,7 +2124,8 @@
function renderDrafts() {
const list = qs('#my-work-list');
const deliveryCenter = draftInbox.partition(lastDrafts);
const displayedDrafts = findQueueItems(lastDrafts, queueFindQuery);
const deliveryCenter = draftInbox.partition(displayedDrafts);
const renderDraftCard = item => {
const index = lastDrafts.indexOf(item);
const isOutbox = item.kind === 'issue-outbox' || item.kind === 'authored-outbox';
@@ -2072,7 +2173,8 @@
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
return '<article class="my-work-card draft-card">' +
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
@@ -2094,6 +2196,7 @@
list.innerHTML = deliverySummary + '<section class="draft-section" aria-label="Queued deliveries">' +
'<h3>Queued deliveries</h3>' + deliveryCards + '</section>' +
'<section class="draft-section" aria-label="Unfinished drafts"><h3>Unfinished drafts</h3>' + draftCards + '</section>';
updateQueueFinder(displayedDrafts.length, lastDrafts.length, false);
qs('#retry-waiting-deliveries').addEventListener('click', async event => {
const button = event.currentTarget;
if (!activeFlushLogin || !deliveryCenter.retryable.length) return;
@@ -2105,16 +2208,13 @@
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
});
list.querySelectorAll('.draft-resume').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (!item) return;
if (item.kind === 'unfiled-issue') {
try {
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
issueCapture.saveDraft(resumed);
refreshMyWorkView();
openCreateIssueSheet();
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
dFS.start(item.capture_id);
await dFS.nextCapture();
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
} else if (item.kind === 'new-issue') openCreateIssueSheet();
else if (item.route) workRoute.open(item.route);
@@ -2189,10 +2289,10 @@
});
});
list.querySelectorAll('.draft-discard').forEach(button => {
button.addEventListener('click', () => {
button.addEventListener('click', async () => {
if (!window.confirm('Discard this unfinished draft?')) return;
const item = lastDrafts[Number(button.dataset.draftIndex)];
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
else if (item) draftInbox.discard(item.id);
@@ -2230,13 +2330,17 @@
qs('#load-more-notifications').hidden = true;
return;
}
const visible = selectedWorkFilter === 'today' ?
const queueItems = selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const visible = findQueueItems(queueItems, queueFindQuery);
updateQueueFinder(visible.length, queueItems.length, incomplete);
const selection = notificationSelection.snapshot();
const selectedIds = new Set(selection.ids);
const workSelectionState = workSelection.snapshot();
const selectedWorkIds = new Set(workSelectionState.ids);
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
const routeItem = routedWorkItem(item);
@@ -2252,10 +2356,13 @@
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
const selectable = selectedWorkFilter === 'update' && selection.active &&
item.has_update && Number.isInteger(item.notification_id);
const selector = selectable ?
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
(selectedIds.has(item.notification_id) ? ' selected' : '');
const workSelectable = workSelectionState.active;
const workId = workSelection.identity(item);
const selector = workSelectable ?
'<label class="work-selector"><input type="checkbox" data-select-work-id="' + escAttr(workId) + '" data-work-index="' + index + '" aria-label="Select work ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedWorkIds.has(workId) ? ' checked' : '') + '> Select</label>' : (selectable ?
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '');
const cardClasses = 'my-work-card' + ((selectable || workSelectable) ? ' selection-active' : '') +
((selectedIds.has(item.notification_id) || selectedWorkIds.has(workId)) ? ' selected' : '');
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
@@ -2296,6 +2403,16 @@
}
});
});
document.querySelectorAll('[data-select-work-id]').forEach(input => {
input.addEventListener('change', () => {
const item = lastMyWork[Number(input.dataset.workIndex)];
const result = input.checked ? workSelection.select(item) : workSelection.toggle(item);
if (result === 'limit') {
input.checked = false;
qs('#my-work-action-status').textContent = 'Select up to 50 work items per batch.';
}
});
});
document.querySelectorAll('[data-review-index]').forEach(button => {
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
});
@@ -2416,17 +2533,33 @@
});
const bulkBar = qs('#bulk-mark-read-bar');
const bulkButton = qs('#bulk-mark-read');
const updateIds = notificationIds(visible);
const selectionControls = qs('#update-selection-controls');
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
qs('#select-updates').hidden = selection.active;
const planningQueue = !['today', 'later', 'draft'].includes(selectedWorkFilter) && visible.length > 0;
selectionControls.hidden = !planningQueue && (selectedWorkFilter !== 'update' || updateIds.length === 0);
qs('#select-work').hidden = !planningQueue || workSelectionState.active || selection.active;
qs('#cancel-work-selection').hidden = !workSelectionState.active;
qs('#select-updates').hidden = selectedWorkFilter !== 'update' || selection.active || workSelectionState.active;
qs('#cancel-update-selection').hidden = !selection.active;
qs('#update-selection-status').textContent = selection.active ?
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
qs('#update-selection-status').textContent = workSelectionState.active ?
workSelectionState.count + ' selected' : (selection.active ?
selection.count + ' of 50 selected' : 'Choose work to plan together or specific updates to mark read.');
bulkBar.hidden = !workSelectionState.active && (selectedWorkFilter !== 'update' || !selection.active);
qs('#load-more-notifications').hidden =
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
bulkButton.disabled = bulkMarkPending || selection.count === 0;
document.querySelectorAll('[data-defer-selected]').forEach(button => {
button.hidden = workSelectionState.active;
button.disabled = bulkMarkPending || selection.count === 0 || !planningOwnerLogin;
});
qs('#batch-add-today').hidden = !workSelectionState.active;
qs('#batch-add-today').disabled = workSelectionState.count === 0 || !planningOwnerLogin;
document.querySelectorAll('[data-batch-defer]').forEach(button => {
button.hidden = !workSelectionState.active;
button.disabled = workSelectionState.count === 0 || !planningOwnerLogin;
});
bulkButton.hidden = workSelectionState.active;
bulkButton.textContent = bulkConfirmationPending ?
'Confirm marking ' + selection.count + ' selected read' :
'Mark ' + selection.count + ' selected read';
@@ -3215,15 +3348,24 @@
qs('#create-issue-repository-results').hidden = true;
qs('#create-issue-title').value = captureDraft.title;
qs('#create-issue-body').value = captureDraft.body;
setIssueFilingMode(Boolean(captureDraft.repository));
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
scheduleIssueDuplicateCheck();
qs('#create-issue-status').textContent = issueCaptureRepositories.length ? '' : 'No accessible repositories are available.';
qs('#create-follow-up-next').hidden = !updateFollowUp?.source();
updateIssueCreateActions();
qs('#create-issue-sheet').classList.add('open');
creatingIssue = true;
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
qs('#create-issue-title').focus();
}
function setIssueFilingMode(enabled) {
qs('#create-issue-filing').hidden = !enabled;
qs('.create-issue-capture-actions').hidden = enabled;
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
}
let suppressCreateDraftOnHistoryClose = false;
@@ -3238,6 +3380,12 @@
clearTimeout(duplicateCheckTimer);
qs('#create-issue-duplicates').hidden = true;
creatingIssue = false;
if (followUpSourceUpdate) {
const source = followUpSourceUpdate;
followUpSourceUpdate = null;
notificationReader.open(source.item, source.detail);
return;
}
qs('#new-issue').focus();
}
@@ -3327,6 +3475,7 @@
}
function closeInlineComposer() {
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-inline-composer').hidden = true;
qs('#review-inline-body').value = '';
activeInlineTarget = null;
@@ -3460,6 +3609,7 @@
workRoute.close();
return;
}
mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'));
qs('#review-sheet').classList.remove('open');
selectedReview = null;
offlineReview = false;
@@ -3480,6 +3630,7 @@
updateReplyAttachmentController.clear();
qs('#update-sheet').classList.remove('open');
selectedUpdate = null;
selectedUpdateDetail = null;
if (restoreTrigger && updateTrigger?.isConnected) updateTrigger.focus();
else qs('[data-work-filter="update"]')?.focus();
}
@@ -3596,7 +3747,6 @@
} else {
setEventStreamStatus('Update failed · showing last activity');
}
// Compatibility fallback for snapshots produced before section metadata.
if (!eventsFreshness && snapshot.freshness?.degraded && !snapshot.freshness.revalidating) {
const retrySeconds = Number(snapshot.freshness.retry_in_seconds) || 0;
setEventStreamStatus('Refresh failed · showing last known data' +
@@ -4036,26 +4186,71 @@
}
});
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
qs('#save-unfiled-issue').addEventListener('click', () => {
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
};
qs('#create-update-follow-up').addEventListener('click', async () => {
if (!selectedUpdate || !selectedUpdateDetail || !await ensureIssueCapture()) return;
const source = {item: selectedUpdate, detail: selectedUpdateDetail};
const state = issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail));
updateFollowUp.stageSource(source.item);
followUpSourceUpdate = source;
closeUpdateSheet(false, false);
await openCreateIssueSheet(false);
if (state.status === 'conflict') {
qs('#shared-content-conflict').hidden = false;
qs('#create-issue-status').textContent = 'Choose which draft to continue.';
qs('#resume-issue-draft').focus();
} else {
qs('#create-issue-status').textContent = 'Follow-up context added. Review, save to Drafts, or create it.';
}
qs('#create-follow-up-next').hidden = false;
});
dFS.bind();
qs('#file-new-issue').addEventListener('click', () => {
if (!qs('#create-issue-title').value.trim()) {
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
qs('#create-issue-title').focus();
return;
}
saveIssueCaptureDraft();
setIssueFilingMode(true);
qs('#create-issue-capture-status').textContent = '';
qs('#create-issue-repository-search').focus();
});
qs('#save-unfiled-issue').addEventListener('click', async () => {
try {
unfiledCaptures.save(captureDraft);
const captureDraft = {
title: qs('#create-issue-title').value.trim(),
body: qs('#create-issue-body').value.trim(),
attachment: await createIssueAttachmentController.serialize(),
};
if (showDraftCapacityDialog(unfiledCaptures)) return;
const savedCapture = await unfiledCaptures.save(captureDraft);
if (rUC) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
requestAnimationFrame(() => {
savedCard?.scrollIntoView({block:'nearest'});
savedCard?.focus({preventScroll:true});
});
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
} catch (error) {
qs('#create-issue-status').textContent = error.message;
qs('#create-issue-capture-status').textContent = error.message;
qs('#create-issue-title').focus();
}
});
bindDraftCapacityDialog();
qs('#use-shared-content').addEventListener('click', () => {
issueCapture.acceptSharedContent();
if (issueCapture.pendingFollowUp()) issueCapture.acceptFollowUp();
else issueCapture.acceptSharedContent();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
@@ -4064,6 +4259,8 @@
});
qs('#resume-issue-draft').addEventListener('click', () => {
issueCapture.discardSharedContent();
issueCapture.discardFollowUp();
updateFollowUp.discardSource();
qs('#shared-content-conflict').hidden = true;
sharedLaunchState = null;
clearSharedLaunchUrl();
@@ -4147,6 +4344,7 @@
qs('#create-issue-form').addEventListener('submit', async event => {
event.preventDefault();
if (event.submitter) createAndStartRequested = event.submitter?.id === 'create-and-start-issue';
const followUpNextRequested = event.submitter?.id === 'create-follow-up-next';
const captureDraft = currentIssueCaptureDraft();
if (!captureDraft.repository || !captureDraft.title) {
qs('#create-issue-status').textContent = 'Choose a repository and add a title.';
@@ -4166,19 +4364,36 @@
}
const button = qs('#submit-new-issue');
const startButton = qs('#create-and-start-issue');
const followUpButton = qs('#create-follow-up-next');
button.disabled = true;
startButton.disabled = true;
followUpButton.disabled = true;
qs('#create-issue-status').textContent = createAndStartRequested ?
'Creating issue and adding it to Today…' : 'Saving for background delivery…';
try {
const durableDraft = {
...captureDraft,
attachment: await createIssueAttachmentController.serialize(),
...(rUC ? { sourceCaptureId: rUC } : {}),
...(createAndStartRequested ? { completionIntent: 'create-and-start' } : {}),
};
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft);
const admission = followUpNextRequested ? (await updateFollowUp.complete({
admit: () => editingOutboxId ? issueOutbox.updateDurably(editingOutboxId, durableDraft) :
issueOutbox.enqueueDurably(durableDraft),
queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId),
advance: source => notificationReader.acceptReadAndNext(lastMyWork, source),
})).admission : (editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
await issueOutbox.enqueueDurably(durableDraft));
const queued = admission.item;
const fS = dFS.current();
if (rUC && (!durableDraft.attachment || admission.background)) {
await unfiledCaptures.completeResume(rUC);
rUC = '';
}
if (fS && !rUC && await dFS.advance(fS.id)) {
refreshMyWorkView();
return;
}
if (!admission.background) {
editingOutboxId = queued.id;
refreshMyWorkView();
@@ -4188,6 +4403,8 @@
return;
}
editingOutboxId = null;
followUpSourceUpdate = null;
if (!followUpNextRequested) updateFollowUp.discardSource();
issueCapture.clearDraft();
createIssueAttachmentController.clear();
suppressCreateDraftOnHistoryClose = true;
@@ -4202,6 +4419,7 @@
qs('#create-issue-status').textContent = error.message + ' Your draft is safe; retry.';
button.disabled = false;
startButton.disabled = !createAndStart.available();
followUpButton.disabled = false;
qs('#create-issue-title').focus();
}
});
@@ -4908,6 +5126,9 @@
if (result?.accepted) qs('#my-work-action-status').textContent =
result.delivery === 'posted' ? 'Reply posted and update marked read.' :
'Reply and read acknowledgement queued for sync.';
if (result?.delivery === 'posted' && result?.next) {
notificationUndo.offer(item, result.next.items);
}
} catch (error) {
qs('#update-reply-status').textContent = error.message + ' Your draft is safe; retry.';
qs('#update-reply').focus();
@@ -4920,7 +5141,8 @@
qs('#mark-update-read-next').addEventListener('click', async () => {
qs('#mark-update-read-next').disabled = true;
try {
await notificationReader.markReadAndNext(lastMyWork);
const result = await notificationReader.markReadAndNext(lastMyWork);
if (result) notificationUndo.offer(result.item, result.items);
} finally {
qs('#mark-update-read-next').disabled = false;
}
@@ -4930,11 +5152,17 @@
button.disabled = true;
try {
const result = await notificationReader.acknowledgeAndNext(lastMyWork);
if (result) qs('#my-work-action-status').textContent = 'Update acknowledged with 👍.';
if (result) notificationUndo.offer(result.item, result.items);
} finally {
button.disabled = offlineWorkMode;
}
});
qs('#undo-notification').addEventListener('click', async () => {
const button = qs('#undo-notification');
button.disabled = true;
const restored = await notificationUndo.run();
button.disabled = restored;
});
qs('#close-review-sheet').addEventListener('click', closeReviewSheet);
qs('#retry-review-load').addEventListener('click', () => {
if (selectedReview) openReviewSheet(selectedReview, reviewTrigger);
@@ -5301,6 +5529,7 @@
qs('#plan-today-error').textContent = '';
renderPlanToday();
});
qs('#build-today-plan').addEventListener('click', () => buildTodayPlan());
function commitPlanToday(start, button) {
const result = planToday.commit({ start, confirmOverCapacity: button.dataset.confirmOverCapacity === 'true' });
if (result === 'saved') {
@@ -5360,6 +5589,44 @@
button.disabled = false;
}
});
qs('#select-work').addEventListener('click', () => {
if (notificationSelection.snapshot().active) notificationSelection.cancel();
workSelection.start();
document.querySelector('[data-select-work-id]')?.focus();
});
qs('#cancel-work-selection').addEventListener('click', () => {
workSelection.cancel();
qs('#select-work').focus();
});
qs('#batch-add-today').addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const result = todayWork.addMany(selectedItems);
if (result.status === 'added') {
result.ids.forEach(id => todaySync.enqueue('add', id));
todaySync.flush();
warmTodayOffline();
qs('#my-work-action-status').textContent = result.ids.length + ' items added to Today without changing Gitea.';
workSelection.cancel();
} else {
qs('#my-work-action-status').textContent = result.status === 'full' ?
'The full selection will not fit in Today. Nothing was added; selection unchanged.' :
(result.status === 'exists' ? 'Every selected item is already in Today.' :
'Could not save Today on this device. Nothing was added; selection unchanged.');
}
refreshMyWorkView();
});
document.querySelectorAll('[data-batch-defer]').forEach(button => button.addEventListener('click', () => {
const ids = new Set(workSelection.snapshot().ids);
const selectedItems = lastMyWork.filter(item => ids.has(workSelection.identity(item)));
const until = laterWork.presetUntil(button.dataset.batchDefer);
const result = laterWork.deferMany(selectedItems, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selectedItems.length + ' items deferred until ' + fmt(until) + '; updates stay unread and Gitea is unchanged.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') workSelection.cancel();
refreshMyWorkView();
}));
qs('#select-updates').addEventListener('click', () => {
notificationSelection.start();
qs('#update-selection-status').textContent = '0 of 50 selected';
@@ -5369,6 +5636,18 @@
notificationSelection.cancel();
qs('#select-updates').focus();
});
document.querySelectorAll('[data-defer-selected]').forEach(button => button.addEventListener('click', () => {
const selection = notificationSelection.snapshot();
const ids = new Set(selection.ids);
const selectedUpdates = lastMyWork.filter(item => item.has_update && ids.has(item.notification_id));
const until = laterWork.presetUntil(button.dataset.deferSelected);
const result = laterWork.deferMany(selectedUpdates, until);
qs('#my-work-action-status').textContent = result === 'deferred' ?
selection.count + ' updates deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
'Could not save Later on this device. Selection unchanged.';
if (result === 'deferred') notificationSelection.cancel();
refreshMyWorkView();
}));
qs('#bulk-mark-read').addEventListener('click', async () => {
const selection = notificationSelection.snapshot();
if (!selection.ids.length || bulkMarkPending) return;
@@ -5395,37 +5674,72 @@
document.querySelectorAll('[data-work-filter]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
button.addEventListener('click', () => {
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
selectedWorkFilter = button.dataset.workFilter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Could not persist My Work filter', e);
}
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
const selectedCount = button.querySelector('[data-work-count]').textContent;
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
renderMyWork();
updateWorkPaginationControls();
selectWorkQueue(button.dataset.workFilter);
});
});
function openDeliveryReceiptRoute() {
if (window.location.hash !== '#/my-work/drafts') return;
qs('[data-work-filter="draft"]').click();
function updateQueueFinder(matches, loaded, incomplete) {
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
const queueLabel = activeQueue?.firstChild?.textContent?.trim() || 'work';
qs('#queue-find-label').textContent = queueLabel;
qs('#clear-queue-find').hidden = !queueFindQuery;
qs('#search-older-work').hidden = !queueFindQuery || matches > 0 || !incomplete;
qs('#queue-find-status').textContent = queueFindQuery ?
(matches + (matches === 1 ? ' match' : ' matches') + ' in ' + queueLabel +
(incomplete ? ' among loaded work.' : '.')) : '';
}
qs('#queue-finder').addEventListener('submit', event => event.preventDefault());
qs('#queue-find-input').addEventListener('input', event => {
queueFindQuery = event.target.value;
renderMyWork();
});
qs('#clear-queue-find').addEventListener('click', () => {
queueFindQuery = '';
qs('#queue-find-input').value = '';
renderMyWork();
qs('#queue-find-input').focus();
});
qs('#search-older-work').addEventListener('click', () => {
const notificationButton = qs('#load-more-notifications');
const workButton = qs('#load-more-work');
const target = selectedWorkFilter === 'update' ? notificationButton : workButton;
if (!target || target.hidden || target.disabled) return;
qs('#queue-find-status').textContent = 'Searching older ' +
(selectedWorkFilter === 'update' ? 'updates…' : 'work…');
target.click();
});
function selectWorkQueue(filter, { preserveRoute = false } = {}) {
const button = qs('[data-work-filter="' + filter + '"]');
if (!button) return false;
const leavingUpdates = selectedWorkFilter === 'update' && filter !== 'update';
selectedWorkFilter = filter;
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
if (workSelection.snapshot().active) workSelection.cancel();
savedWorkFilter = selectedWorkFilter;
launchFilterResolved = true;
try {
sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter);
} catch (e) {
console.warn('Filter save failed', e);
}
document.querySelectorAll('[data-work-filter]').forEach(item =>
item.setAttribute('aria-pressed', String(item === button))
);
const selectedCount = button.querySelector('[data-work-count]').textContent;
qs('#active-work-queue').textContent = button.firstChild.textContent.trim() + ' (' + selectedCount + ')';
renderMyWork();
updateWorkPaginationControls();
if (!preserveRoute) workRoute.queue(filter);
return true;
}
function openWorkQueueRoute(filter) {
if (!selectWorkQueue(filter, { preserveRoute:true })) return;
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
window.addEventListener('hashchange', openDeliveryReceiptRoute);
openDeliveryReceiptRoute();
qs('#work-milestone-filter').addEventListener('change', event => {
selectedWorkMilestone = event.target.value;
try { sessionStorage.setItem(WORK_MILESTONE_KEY, selectedWorkMilestone); }
catch (e) { console.warn('Could not persist My Work milestone lane', e); }
catch (e) { console.warn('Milestone save failed', e); }
renderMyWork();
if (workSession.active()) workSession.reconcile();
});
+65
View File
@@ -0,0 +1,65 @@
function showDraftCapacityDialog(captures) {
const state = captures.capacity();
if (!state.full) return false;
const oldest = state.oldest;
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
qs('#draft-capacity-status').textContent = '';
qs('#draft-capacity-sheet').hidden = false;
qs('#keep-editing-draft').focus();
return true;
}
function bindDraftCapacityDialog() {
qs('#keep-editing-draft').addEventListener('click', () => {
qs('#draft-capacity-sheet').hidden = true;
qs('#save-unfiled-issue').focus();
});
qs('#review-full-drafts').addEventListener('click', () => {
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
saveIssueCaptureDraft();
qs('#draft-capacity-sheet').hidden = true;
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => {
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
card?.scrollIntoView({block:'nearest'});
card?.focus({preventScroll:true});
});
});
qs('#replace-oldest-draft').addEventListener('click', async () => {
const oldest = unfiledCaptures.capacity().oldest;
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
return;
}
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
const button = qs('#replace-oldest-draft');
button.disabled = true;
try {
const draft = {
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
attachment:await createIssueAttachmentController.serialize(),
};
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
qs('#draft-capacity-sheet').hidden = true;
issueCapture.clearDraft();
qs('#create-issue-title').value = '';
qs('#create-issue-body').value = '';
createIssueAttachmentController.clear();
closeCreateIssueSheet(true, false);
qs('[data-work-filter="draft"]').click();
mobileTaskDock.select('queues');
refreshMyWorkView();
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
} catch (error) {
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
} finally { button.disabled = false; }
});
}
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
+92
View File
@@ -0,0 +1,92 @@
function createDraftFilingSession({list}) {
let orderedIds = [];
let currentId = '';
function visibleIds() {
const available = new Set(list().map(item => item.id));
orderedIds = orderedIds.filter(id => available.has(id));
for (const item of list()) {
if (!orderedIds.includes(item.id)) orderedIds.push(item.id);
}
return orderedIds;
}
function state() {
const ids = visibleIds();
if (!ids.length) {
currentId = '';
return null;
}
if (!ids.includes(currentId)) currentId = ids[0];
const index = ids.indexOf(currentId);
return {id:currentId, position:index + 1, total:ids.length, remaining:ids.length - index - 1};
}
function start(id) {
orderedIds = list().map(item => item.id);
if (!orderedIds.includes(id)) throw new Error('This capture is no longer available.');
currentId = id;
return state();
}
function move(offset) {
const ids = visibleIds();
if (!ids.length) return state();
const index = Math.max(0, ids.indexOf(currentId));
currentId = ids[(index + offset + ids.length) % ids.length];
return state();
}
function removeCurrentAndNext(removedId) {
const previousIds = orderedIds.slice();
const removedIndex = Math.max(0, previousIds.indexOf(removedId));
orderedIds = previousIds.filter(id => id !== removedId);
const ids = visibleIds();
if (!ids.length) return state();
currentId = ids[Math.min(removedIndex, ids.length - 1)];
return state();
}
const session = {start, current:state, next:()=>move(1), previous:()=>move(-1), removeCurrentAndNext};
session.attach = (qs, dependencies) => {
const openCapture = async captureId => {
const resumed = await dependencies.captures.resume(captureId, dependencies.getLogin());
dependencies.issueCapture.saveDraft(resumed);
dependencies.setResumedId(captureId);
await dependencies.openSheet();
dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment);
dependencies.setFilingMode(true);
session.render();
};
return Object.assign(session, {
nextCapture() { const state = session.current(); return state ? openCapture(state.id) : null; },
render() {
const state = session.current();
qs('#draft-filing-session').hidden = !state;
if (!state) return;
qs('#draft-filing-progress').textContent = `Draft ${state.position} of ${state.total}`;
qs('#draft-filing-remaining').textContent = state.remaining ? `${state.remaining} remaining` : 'Last Draft';
qs('#previous-draft').disabled = qs('#skip-draft').disabled = state.total < 2;
qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft';
},
bind() {
[['#previous-draft','previous'],['#skip-draft','next']].forEach(([selector, method]) =>
qs(selector).addEventListener('click', async () => {
const state = session[method]();
if (state) await openCapture(state.id);
})
);
},
async advance(completedId) {
const state = session.removeCurrentAndNext(completedId);
if (state) { await openCapture(state.id); return true; }
qs('#draft-filing-session').hidden = true;
qs('#submit-new-issue').textContent = 'Create & assign to me';
return false;
},
});
};
return session;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createDraftFilingSession;
+99 -17
View File
@@ -163,10 +163,21 @@
</div>
</section>
<div class="update-selection-controls" id="update-selection-controls" hidden>
<button id="select-work" type="button">Select work</button>
<button id="cancel-work-selection" type="button" hidden>Cancel selection</button>
<button id="select-updates" type="button">Select updates</button>
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
</div>
<form class="queue-finder" id="queue-finder" role="search">
<label for="queue-find-input">Find in <span id="queue-find-label">All</span></label>
<div class="queue-finder-row">
<input id="queue-find-input" type="search" autocomplete="off" aria-label="Find in active work queue" placeholder="Repository, #number, or title" />
<button id="clear-queue-find" type="button" hidden>Clear</button>
</div>
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
<button id="search-older-work" type="button" hidden>Search older work</button>
</form>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
@@ -178,7 +189,14 @@
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
<div class="small" id="work-route-share-status" aria-live="polite"></div>
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
<button id="bulk-mark-read" type="button"></button>
<div class="my-work-bulk-actions">
<button id="batch-add-today" type="button" hidden>Add to Today</button>
<button data-batch-defer="today" type="button" hidden>Later today</button>
<button data-batch-defer="tomorrow" type="button" hidden>Tomorrow</button>
<button id="defer-selected-today" data-defer-selected="today" type="button">Later today</button>
<button id="defer-selected-tomorrow" data-defer-selected="tomorrow" type="button">Tomorrow</button>
<button id="bulk-mark-read" type="button"></button>
</div>
</div>
</section>
<aside class="sidebar">
@@ -254,6 +272,14 @@
<label class="plan-today-available" for="plan-today-available">Available today
<span><input id="plan-today-available" type="number" inputmode="numeric" min="15" max="1440" step="15" placeholder="Minutes" /> min</span>
</label>
<button class="plan-today-build" id="build-today-plan" type="button">Build my Today</button>
<div class="small plan-today-build-status" id="plan-today-build-status" role="status" aria-live="polite"></div>
<section class="plan-today-estimates" id="plan-today-estimates" aria-labelledby="plan-today-estimates-heading" hidden>
<h3 id="plan-today-estimates-heading">Estimate to finish your plan</h3>
<p class="small muted">Add minutes for ready work and your plan will re-fit immediately.</p>
<div id="plan-today-estimate-list"></div>
</section>
<div class="plan-today-skipped" id="plan-today-skipped"></div>
<div class="small plan-today-error" id="plan-today-error" role="alert"></div>
<button class="discard-recap-replan" id="discard-recap-replan" type="button" hidden>Discard recap feedback</button>
<section aria-labelledby="today-plan-heading">
@@ -514,7 +540,7 @@
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
<section class="create-issue-panel">
<div class="create-issue-header">
<h3 id="create-issue-heading">New issue</h3>
<h3 id="create-issue-heading">Capture work</h3>
<button id="cancel-new-issue" type="button">Cancel</button>
</div>
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
@@ -526,23 +552,16 @@
</div>
</aside>
<form class="create-issue-form" id="create-issue-form">
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<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>
<label for="create-issue-body">Description <span class="small">Optional</span>
<label for="create-issue-body">Note <span class="small">Optional</span>
<textarea id="create-issue-body" maxlength="10000"></textarea>
</label>
<div class="create-issue-capture-actions">
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
<button id="file-new-issue" type="button">File now</button>
</div>
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
<div class="issue-attachment-controls">
@@ -556,6 +575,26 @@
</div>
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
</section>
<section class="create-issue-filing" id="create-issue-filing" hidden>
<aside class="draft-filing-session" id="draft-filing-session" aria-live="polite" hidden>
<strong id="draft-filing-progress"></strong>
<span class="small" id="draft-filing-remaining"></span>
<div class="draft-filing-session-actions">
<button id="previous-draft" type="button">Previous</button>
<button id="skip-draft" type="button">Skip</button>
</div>
</aside>
<div class="create-issue-repository-picker">
<label for="create-issue-repository-search">Repository search
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
</label>
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
<label for="create-issue-repository">Selected repository
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
</label>
</div>
<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-milestone">Milestone <span class="small">Optional</span>
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
<option value="">No milestone</option>
@@ -578,15 +617,31 @@
</section>
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<button id="save-unfiled-issue" type="button">Save for filing</button>
<button id="submit-new-issue" type="submit">Create &amp; assign to me</button>
<button id="create-follow-up-next" type="submit" hidden>Create follow-up &amp; next</button>
<button id="create-and-start-issue" type="submit">Create &amp; start</button>
<div id="create-issue-status" class="small" aria-live="assertive"></div>
</div>
</section>
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
</form>
</section>
</div>
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
<section class="draft-capacity-panel">
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
<div class="draft-capacity-actions">
<button id="review-full-drafts" type="button">Review Drafts</button>
<button id="keep-editing-draft" type="button">Keep editing</button>
<button id="replace-oldest-draft" type="button">Replace oldest &amp; save</button>
</div>
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
</section>
</div>
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
<section class="update-sheet-panel">
<div class="update-sheet-header">
@@ -638,6 +693,7 @@
</div>
<button class="share-work-route" type="button">Share</button>
<button id="acknowledge-update-next" type="button" hidden aria-label="Acknowledge and open next update">👍 Acknowledge &amp; next</button>
<button id="create-update-follow-up" type="button" hidden>Create follow-up</button>
<button id="mark-update-read-next" type="button">Mark read &amp; next</button>
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date &amp; time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
@@ -652,6 +708,11 @@
</section>
</div>
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
<span id="notification-undo-status"></span>
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@@ -818,13 +879,27 @@
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
</section>
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
<section class="mobile-queue-panel">
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
<button data-mobile-queue="recaps" type="button"><span><strong>Recaps</strong><small>History</small></span></button>
</div>
</section>
</dialog>
<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"><span id="mobile-work-label">Work</span></button>
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <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>
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
</nav>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
@@ -838,6 +913,8 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
<script src="static/background-issue-sync.js"></script>
<script src="static/issue-outbox.js"></script>
@@ -848,7 +925,9 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/notification-undo.js"></script>
<script src="static/card-planning.js"></script>
<script src="static/work-selection.js"></script>
<script src="static/today-work.js"></script>
<script src="static/today-timer.js"></script>
<script src="static/today-recap.js"></script>
@@ -857,10 +936,12 @@
<script src="static/comment-next.js"></script>
<script src="static/update-reply-read-next.js"></script>
<script src="static/plan-today.js"></script>
<script src="static/plan-today-readiness.js"></script>
<script src="static/plan-today-preview.js"></script>
<script src="static/today-sync.js"></script>
<script src="static/today-rollover.js"></script>
<script src="static/update-ownership.js"></script>
<script src="static/update-follow-up.js"></script>
<script src="static/later-work.js"></script>
<script src="static/later-sync.js"></script>
<script src="static/later-and-start.js"></script>
@@ -882,6 +963,7 @@
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
+15
View File
@@ -51,6 +51,8 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
status: 'queued',
queuedAt: Number(now()),
};
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
if (sourceCaptureId) item.sourceCaptureId = sourceCaptureId;
if (draft?.completionIntent === 'create-and-start') item.completionIntent = 'create-and-start';
const attachment = captureAttachment(draft?.attachment);
if (attachment) item.attachment = attachment;
@@ -76,6 +78,9 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
function enqueue(draft, mirror = true) {
const items = read();
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && items.find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) return { ...existing };
const item = prepareItem(draft);
items.push(item);
write(items, mirror);
@@ -83,6 +88,16 @@ function createIssueOutbox({ storage, fetchJson, coordinator, backgroundSync, ge
}
async function enqueueDurably(draft) {
const sourceCaptureId = String(draft?.sourceCaptureId || '').trim().slice(0, 128);
const existing = sourceCaptureId && read().find(item => item.sourceCaptureId === sourceCaptureId);
if (existing) {
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
return { item: { ...existing }, background: false, durability: 'foreground-only', reused: true };
}
await backgroundSync.reconcile(read());
await backgroundSync.requestSync();
return { item: { ...existing }, background: true, durability: 'background', reused: true };
}
if (!backgroundSync?.reconcile || !backgroundSync?.requestSync) {
const item = enqueue(draft, false);
return { item, background: false, durability: 'foreground-only' };
+17 -1
View File
@@ -52,6 +52,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return 'deferred';
}
function deferMany(items, until) {
const key = storageKey();
const wake = new Date(until);
const entries = (items || []).map(item => [identity(item), item]);
if (!key) return 'unavailable';
if (!entries.length || entries.some(([id]) => !id) || Number.isNaN(wake.getTime()) || wake <= now()) {
return 'invalid';
}
const records = read();
const wakeAt = wake.toISOString();
entries.forEach(([id]) => { records[id] = wakeAt; });
if (!write(records)) return 'unavailable';
entries.forEach(([id]) => onChange('defer', id, wakeAt));
return 'deferred';
}
function presetUntil(preset) {
const current = new Date(now());
if (preset === 'today') return new Date(current.getTime() + 4 * 60 * 60 * 1000);
@@ -127,7 +143,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
return { active, later };
}
return { identity, read, adopt, defer, restore, presetUntil, partition };
return { identity, read, adopt, defer, deferMany, restore, presetUntil, partition };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
+25
View File
@@ -0,0 +1,25 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileQueueLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileQueueLauncher(options) {
const emptyMessages = {
attention: 'No items need attention.',
update: 'No unread updates are ready to open.',
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
function open(name) {
if (name === 'today') return options.openToday();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
options.announce(emptyMessages[name] || 'No work is ready to open.');
return 'empty';
}
action.click();
return 'opened';
}
return { open };
});
+46 -3
View File
@@ -9,14 +9,16 @@
let wasHidden = false;
function select(name) {
if (!buttons[name]) return false;
Object.entries(buttons).forEach(([key, button]) => {
if (key === name) button.setAttribute('aria-current', 'page');
else button.removeAttribute('aria-current');
});
return true;
}
function refreshVisibility() {
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
nav.hidden = hidden;
if (options.sessionHud) {
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
@@ -26,14 +28,37 @@
wasHidden = hidden;
}
function openQueues() {
if (!options.queueSheet || options.queueSheet.open) return;
options.queueSheet.showModal();
}
function closeQueues() {
options.queueSheet?.open && options.queueSheet.close();
}
function start() {
Object.entries(buttons).forEach(([name, button]) => {
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
options.actions[name]();
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
});
});
if (options.queueSheet) {
options.queueClose?.addEventListener('click', closeQueues);
options.queueSheet.addEventListener('cancel', event => {
event.preventDefault();
closeQueues();
});
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name, row);
});
});
}
options.observe(refreshVisibility, overlays);
refreshVisibility();
}
@@ -43,6 +68,7 @@
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
@@ -67,5 +93,22 @@
else nav.removeAttribute('data-attention');
}
return {start, refreshVisibility, updateAttention, updateWork};
function updateQueues(counts) {
const names = 'today attention update later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
}
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items');
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
});
+2
View File
@@ -6,6 +6,7 @@
if (options.isTodayActive()) return 'continue';
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
return 'work';
}
@@ -14,6 +15,7 @@
if (current === 'continue') options.continueToday();
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.openFallback();
return current;
}
+14 -2
View File
@@ -362,7 +362,7 @@ function createNotificationReader({
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {
@@ -404,7 +404,8 @@ function createNotificationReader({
return true;
},
acceptReadAndNext(items, item = selected) {
if (!item || selected !== item) return false;
if (!item || (selected && selected !== item)) return false;
selected = item;
return advanceAfterRead(items, item);
},
async loadOlder() {
@@ -813,6 +814,16 @@ function summarizeMyWork(items) {
return (updates ? updateLabel + ' · ' : '') + reviewLabel + ' · ' + assignedLabel;
}
function findQueueItems(items, query) {
const needle = String(query || '').trim().toLowerCase();
if (!needle) return (items || []).slice();
return (items || []).filter(item => {
const number = Number.isInteger(item?.number) ? '#' + item.number : '';
return [item?.repository, item?.key, number, item?.title]
.some(value => String(value || '').toLowerCase().includes(needle));
});
}
function countMyWork(items) {
return {
all: items.length,
@@ -836,6 +847,7 @@ if (typeof module !== 'undefined' && module.exports) {
buildMyWork.replaceIssueMilestone = replaceIssueMilestone;
buildMyWork.removeIssue = removeIssue;
buildMyWork.summarizeMyWork = summarizeMyWork;
buildMyWork.findQueueItems = findQueueItems;
buildMyWork.countMyWork = countMyWork;
buildMyWork.acknowledgeNotification = acknowledgeNotification;
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
+85
View File
@@ -0,0 +1,85 @@
function createNotificationUndo({ restore, onItems, onStatus, onOffer = () => {}, onClear = () => {} }) {
let offered = null;
let pending = false;
return {
offer(item, items) {
if (!item || !Number.isInteger(item.notification_id)) return false;
offered = { item, items: Array.isArray(items) ? [...items] : [] };
const label = item.key || 'Update';
onStatus(label + ' marked read. Undo?');
onOffer(item);
return true;
},
async run() {
if (!offered || pending) return false;
const current = offered;
const label = current.item.key || 'Update';
pending = true;
onStatus('Restoring ' + label + '…');
try {
await restore(current.item.notification_id);
if (offered !== current) return false;
const restored = [current.item].concat(current.items.filter(
item => item?.notification_id !== current.item.notification_id
));
offered = null;
onItems(restored, current.item);
onStatus(label + ' is unread again.');
onClear();
return true;
} catch (_error) {
onStatus('Could not restore ' + label + '. Retry Undo.');
return false;
} finally {
pending = false;
}
},
clear() {
offered = null;
onClear();
},
};
}
async function requestNotificationUnread(notificationId) {
const response = await fetch('api/v1/notifications/' + encodeURIComponent(notificationId) + '/unread', {
method: 'PATCH', headers: { Accept: 'application/json' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || 'Restore unread failed.');
return payload;
}
function createDashboardNotificationUndo({ restore, getItems, setItems, getNotifications, setNotifications, refresh, select }) {
let timer = null;
const controller = createNotificationUndo({
restore,
onItems: (items, restored) => {
setItems(items);
const notifications = getNotifications();
if (!notifications.some(item => item.id === restored.notification_id)) {
setNotifications([{ id:restored.notification_id }].concat(notifications));
}
refresh();
},
onStatus: message => {
select('#notification-undo-status').textContent = message;
select('#my-work-action-status').textContent = message;
},
onOffer: item => {
clearTimeout(timer);
select('#notification-undo').hidden = false;
select('#undo-notification').disabled = false;
select('#undo-notification').setAttribute('aria-label', 'Undo marking ' + (item.key || 'update') + ' read');
timer = setTimeout(() => controller.clear(), 10000);
},
onClear: () => { select('#notification-undo').hidden = true; },
});
return controller;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = createNotificationUndo;
module.exports.createDashboardNotificationUndo = createDashboardNotificationUndo;
}
+41
View File
@@ -0,0 +1,41 @@
function createPlanTodayReadiness({ identity, inspect, concurrency = 3 }) {
const workerCount = Math.max(1, Math.floor(Number(concurrency) || 1));
let generation = 0;
async function run(items) {
const runGeneration = ++generation;
const candidates = Array.from(items || []);
const results = new Array(candidates.length);
let next = 0;
async function worker() {
while (next < candidates.length) {
const index = next;
next += 1;
const item = candidates[index];
let result;
try {
result = await inspect(item);
} catch (_error) {
result = { status:'unverified' };
}
results[index] = [identity(item), result];
}
}
await Promise.all(Array.from(
{ length:Math.min(workerCount, candidates.length) },
() => worker(),
));
if (runGeneration !== generation) return null;
return Object.fromEntries(results);
}
function cancel() {
generation += 1;
}
return { run, cancel };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanTodayReadiness;
+48 -4
View File
@@ -7,6 +7,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -33,6 +35,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
recommendations = {};
recommendationAware = actualMinutes !== null;
buildState = null;
buildReadiness = null;
for (const [id, minutes] of Object.entries(actualMinutes || {})) {
if (draftIds.includes(id) && Number.isInteger(minutes) && minutes >= 5 && minutes <= 1440 &&
estimates[id] !== minutes) recommendations[id] = minutes;
@@ -74,6 +78,8 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -88,10 +94,12 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
}
function setEstimate(id, minutes) {
if (!openState || !draftIds.includes(id)) return false;
const resolvingBuildEstimate = buildState?.needs_estimate?.includes(id);
if (!openState || (!draftIds.includes(id) && !resolvingBuildEstimate)) return false;
capacityAware = true;
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
else delete estimates[id];
if (!Number.isInteger(minutes) || minutes < 5 || minutes > 1440) return false;
estimates[id] = minutes;
if (resolvingBuildEstimate && buildReadiness) buildRecommendation(buildReadiness);
return true;
}
@@ -104,6 +112,41 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return true;
}
function buildRecommendation(readiness = {}) {
if (!openState) return null;
buildReadiness = { ...readiness };
const selected = [];
const reasons = {};
const skipped = [];
const needsEstimate = [];
let used = 0;
for (const [id, item] of itemsById) {
if (selected.length >= limit) break;
const check = readiness[id] || { status:'unverified' };
if (check.status !== 'ready') {
skipped.push({
id,
status:check.status === 'blocked' ? 'blocked' : 'unverified',
reason:check.reason || (check.status === 'blocked' ? 'Blocked' : 'Could not verify readiness'),
});
continue;
}
const minutes = estimates[id];
if (!Number.isInteger(minutes)) {
needsEstimate.push(id);
continue;
}
if (capacityMinutes !== null && used + minutes > capacityMinutes) continue;
selected.push(id);
reasons[id] = item.reason || item.attention_reason || 'Ranked My Work';
used += minutes;
}
draftIds = selected;
capacityAware = true;
buildState = { selected:[...selected], reasons, skipped, needs_estimate:needsEstimate };
return buildState;
}
function commit({ start: startAfterSave = false, confirmOverCapacity = false } = {}) {
if (!openState) return 'closed';
const ids = [...draftIds];
@@ -138,6 +181,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
@@ -149,7 +193,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return [...itemsById.entries()].filter(([id]) => !draftIds.includes(id)).map(([, value]) => value);
}
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, commit, snapshot, item, candidates };
return { open, toggle, move, cancel, setCapacity, setEstimate, applyRecommendation, buildRecommendation, commit, snapshot, item, candidates };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createPlanToday;
+1
View File
@@ -54,6 +54,7 @@
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches?.keys?.() || [];
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
+4
View File
@@ -128,6 +128,7 @@
}
const labels = {
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
pull_review_approved: 'Pull request approved',
@@ -141,6 +142,9 @@
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [event.device_label, event.method, event.target,
event.kind === 'passkey_counter_anomaly'
? 'Remove and re-enroll this passkey if the alert repeats'
: null,
event.status === 'pending' ? 'Outcome confirmation pending' : null]
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
+39 -5
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v93';
const CACHE = 'stackchain-dashboard-shell-v97';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -21,6 +21,8 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
BASE + 'static/issue-outbox.js',
BASE + 'static/authored-outbox.js',
@@ -30,7 +32,9 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/notification-undo.js',
BASE + 'static/card-planning.js',
BASE + 'static/work-selection.js',
BASE + 'static/today-work.js',
BASE + 'static/today-timer.js',
BASE + 'static/today-recap.js',
@@ -39,10 +43,12 @@ const SHELL = [
BASE + 'static/comment-next.js',
BASE + 'static/update-reply-read-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
BASE + 'static/update-follow-up.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
@@ -64,6 +70,7 @@ const SHELL = [
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
@@ -73,6 +80,8 @@ const SHELL = [
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
];
async function fetchNavigation(request) {
const controller = new AbortController();
@@ -117,8 +126,21 @@ async function withSessionCsrf(work) {
}
}
async function deletePrivateDatabase(name) {
if (!indexedDB) return;
await new Promise((resolve, reject) => {
let request;
try { request = indexedDB.deleteDatabase(name); }
catch (error) { reject(error); return; }
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
});
}
async function purgeRevokedSessionData() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -155,6 +177,7 @@ async function offlineLeaseState(cache) {
async function expiredOfflineResponse() {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
@@ -248,10 +271,16 @@ self.addEventListener('install', event => {
});
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
)).then(() => self.clients.claim()));
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
.map(key => caches.delete(key))
);
const cache = await caches.open(CACHE);
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
await self.clients.claim();
})());
});
self.addEventListener('sync', event => {
@@ -269,6 +298,7 @@ self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
event.ports?.[0]?.postMessage({ ok: true });
} catch (error) {
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
@@ -418,5 +448,9 @@ self.addEventListener('fetch', event => {
}
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
return;
}
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
}
});
+1
View File
@@ -456,6 +456,7 @@
await stopWorkerOutbox();
await deletePrivateDatabase('stackchain-background-outbox-v1');
await deletePrivateDatabase('stackchain-offline-work-v2');
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
} catch (_error) {
const error = new Error('Could not clear private queued work from this device.');
onClearError(error);
+16 -1
View File
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
function addMany(items) {
if (!storageKey()) return { status: 'unavailable', ids: [] };
const current = read();
const additions = [];
for (const item of items || []) {
const id = identity(item);
if (!id) return { status: 'unavailable', ids: [] };
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
}
if (!additions.length) return { status: 'exists', ids: [] };
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
return write(current.concat(additions)) ?
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
@@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+97 -10
View File
@@ -1,5 +1,6 @@
function createUnfiledCaptures({
storage,
attachmentStore = null,
getCaptureLogin = () => '',
getCurrentLogin = () => '',
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
}
function save(note) {
function capacity() {
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
}
function prepare(note) {
const title = String(note?.title || '').trim().slice(0, 255);
const body = String(note?.body || '').trim().slice(0, 10000);
if (!title) throw new Error('Add a title before saving.');
const ownerLogin = String(getCaptureLogin() || '').trim();
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
write(items);
return item;
const attachment = note?.attachment;
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
if (hasAttachment && !attachmentStore) {
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
}
return {title, body, ownerLogin, attachment, hasAttachment};
}
function persist(prepared, existing, removed = null) {
const item = {
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
};
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
const writeItems = () => {
try { write(items); }
catch (error) {
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
return item;
};
const stage = prepared.hasAttachment
? Promise.resolve(attachmentStore.put(item.id, {
filename:String(prepared.attachment.filename).slice(0, 255),
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
}))
: null;
if (!stage && !removed?.hasAttachment) return writeItems();
return Promise.resolve(stage).then(async () => {
if (!removed?.hasAttachment) return writeItems();
const removedAttachment = await attachmentStore.get(removed.id);
try {
await attachmentStore.delete(removed.id);
} catch (error) {
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
throw error;
}
try { return writeItems(); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
throw error;
}
});
}
function save(note) {
const prepared = prepare(note);
const existing = read();
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
return persist(prepared, existing);
}
function replaceOldest(note, expectedOldestId) {
const prepared = prepare(note);
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
const oldest = existing.at(-1);
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
throw new Error('Drafts changed. Review them before replacing anything.');
}
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
}
function discard(id) {
const items = read();
const remaining = items.filter(item => item.id !== id);
if (remaining.length === items.length) return false;
write(remaining);
return true;
const removed = items.find(item => item.id === id);
if (!removed?.hasAttachment) {
write(remaining);
return true;
}
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
try { write(remaining); }
catch (error) {
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
throw error;
}
return true;
})
);
}
function resume(id, confirmedLogin) {
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
throw new Error('Reconnect with the account that saved this capture.');
}
discard(id);
return {repository:'', title:item.title, body:item.body, labelIds:[]};
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
if (!item.hasAttachment) return draft;
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
}
return {...draft, attachment};
});
}
return {list, save, discard, resume};
function completeResume(id) { return discard(id); }
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
+87
View File
@@ -0,0 +1,87 @@
function createUpdateFollowUp({ storage = null, getLogin = () => '' } = {}) {
const sourceKey = 'stackchain.update-follow-up-source.v1';
const clean = (value, limit) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit);
const safeRepository = value => {
const candidate = String(value || '');
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(candidate) &&
candidate.split('/').every(part => part !== '.' && part !== '..') ? candidate : '';
};
const safeUrl = value => {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.href : '';
} catch (_error) { return ''; }
};
function draft(detail = {}) {
const source = safeUrl(detail.url || detail.latest_comment?.url);
const context = clean(detail.latest_comment?.body || detail.subject_body, 9000);
const title = clean(detail.title, 228);
const sections = [];
if (source) sections.push('Source: ' + source);
if (context) sections.push('Latest context:\n> ' + context.replace(/\n/g, '\n> '));
return {
repository: safeRepository(detail.repository),
title: ('Follow up: ' + (title || 'Unread update')).slice(0, 240),
body: sections.join('\n\n').slice(0, 9500),
};
}
function readSource() {
const login = String(getLogin() || '').trim();
if (!login) return null;
try {
const record = JSON.parse(storage?.getItem(sourceKey) || 'null');
return record?.version === 1 && record.ownerLogin === login &&
Number.isInteger(record.source?.notification_id) && record.source.notification_id > 0 ? record : null;
} catch (_error) { return null; }
}
function writeSource(record) {
storage?.setItem(sourceKey, JSON.stringify(record));
}
function stageSource(item = {}) {
const ownerLogin = String(getLogin() || '').trim();
const notificationId = Number(item.notification_id);
if (!ownerLogin) throw new Error('Confirm your Gitea account before continuing this follow-up.');
if (!Number.isInteger(notificationId) || notificationId <= 0) throw new Error('Choose a valid source update.');
const source = {
notification_id: notificationId,
repository: safeRepository(item.repository),
number: Number.isInteger(item.number) && item.number > 0 ? item.number : null,
title: clean(item.title, 240), kind: 'update', has_update: true,
};
writeSource({ version:1, ownerLogin, source, admission:null });
return { ...source };
}
function source() {
const record = readSource();
return record ? { ...record.source } : null;
}
function discardSource() {
if (!readSource()) return false;
storage?.removeItem(sourceKey);
return true;
}
async function complete({ admit, queueRead, advance }) {
const record = readSource();
if (!record) throw new Error('Reopen the source update before continuing.');
let admission = record.admission;
if (!admission) {
admission = await admit();
writeSource({ ...record, admission });
}
await queueRead(record.source.notification_id);
const advanced = await advance({ ...record.source });
storage?.removeItem(sourceKey);
return { admission, advanced:Boolean(advanced) };
}
return { draft, stageSource, source, discardSource, complete };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createUpdateFollowUp;
+26
View File
@@ -6,6 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@@ -15,6 +16,10 @@
function parse(fragment) {
const parts = String(fragment || '').split('/');
if (parts[0] !== '#' || parts[1] !== 'my-work') return null;
const queue = parts[2]?.replace(/s$/, '');
if (queueFilters.includes(queue) && parts.length === 3) {
return { kind: 'queue', filter: queue };
}
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
@@ -38,6 +43,7 @@
}
function sameRoute(item, route) {
if (route.kind === 'queue') return false;
if (route.kind === 'update') {
return Number(item.notification_id) === route.notification_id;
}
@@ -48,6 +54,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -100,6 +107,16 @@
active = '';
return;
}
if (route.kind === 'queue') {
if (!ready) return;
resolution += 1;
resolving = '';
if (active && active !== fragment) onClose();
if (active === fragment) return;
active = fragment;
onQueue(route.filter);
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
@@ -134,6 +151,15 @@
onOpen(item);
return true;
},
queue(filter) {
if (!queueFilters.includes(filter)) return false;
const name = filter + (['update', 'draft'].includes(filter) ? 's' : '');
const fragment = '#/my-work/' + name;
if (location.hash !== fragment) history.pushState(null, '', fragment);
active = fragment;
onQueue(filter);
return true;
},
close() {
if (parse(location.hash)) history.back();
else {
+68
View File
@@ -0,0 +1,68 @@
function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
let active = false;
const selected = new Set();
function identity(item) {
if (!item) return '';
const kind = item.is_review ? 'review' : (item.kind || 'work');
const number = Number.isInteger(item.number) ? item.number : '';
const notification = Number.isInteger(item.notification_id) ? item.notification_id : '';
return [kind, item.repository || '', number, notification].join(':');
}
function changed() {
const state = snapshot();
onChange(state);
return state;
}
function start() {
active = true;
selected.clear();
return changed();
}
function cancel() {
active = false;
selected.clear();
return changed();
}
function select(item) {
if (!active) return 'inactive';
const id = identity(item);
if (!id) return 'invalid';
if (selected.has(id)) return 'selected';
if (selected.size >= maximum) return 'limit';
selected.add(id);
changed();
return 'selected';
}
function toggle(item) {
if (!active) return 'inactive';
const id = identity(item);
if (!id) return 'invalid';
if (selected.has(id)) {
selected.delete(id);
changed();
return 'removed';
}
return select(item);
}
function retain(items) {
const retained = new Set((items || []).map(identity).filter(Boolean));
Array.from(selected).forEach(id => { if (!retained.has(id)) selected.delete(id); });
return changed();
}
function snapshot() {
return { active, count: selected.size, ids: Array.from(selected) };
}
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
+15 -4
View File
@@ -22,14 +22,16 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": ("static/create-issue-sheet.js",),
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js",
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js",
),
}
CACHE_DECLARATION = re.compile(
@@ -114,11 +116,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
for source in sources:
if source != WORKER_RUNTIME_SOURCE:
worker = worker.replace(f" BASE + '{source}',\n", "")
eager_feature = feature_bundles["today-timer"]
optional_features = {
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
}
worker = worker.replace(
" BASE + 'static/dashboard.css',\n",
" BASE + 'static/dashboard.css',\n"
+ f" BASE + '{core.runtime_name}',\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
+ f" BASE + '{eager_feature.runtime_name}',\n",
)
worker = worker.replace(
"const OPTIONAL_FEATURES = [\n",
"const OPTIONAL_FEATURES = [\n"
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
)
worker = CACHE_DECLARATION.sub(
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
+8
View File
@@ -804,6 +804,14 @@ async def mark_notification_read(thread_id: int) -> None:
response.raise_for_status()
async def mark_notification_unread(thread_id: int) -> None:
response = await _get_client().patch(
f"/api/v1/notifications/threads/{thread_id}?to-status=unread",
headers=_auth(),
)
response.raise_for_status()
async def acknowledge_notification(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
+48 -2
View File
@@ -48,6 +48,7 @@ from src.live_snapshot_store import (
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.passkey_store import PasskeyStore
from src.push_notifications import PushConfiguration, dispatch_unread_updates
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
from src.push_subscription_store import PushSubscriptionStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.security_event_store import SecurityEventStore, SecurityEventStoreError
@@ -1595,9 +1596,19 @@ async def verify_passkey_authorization(
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
store.advance_counter,
stored.credential_id,
expected=stored.sign_count,
new=verified.new_sign_count,
)
if not updated:
await asyncio.to_thread(
_security_event_store().record,
"passkey_counter_anomaly",
method="passkey",
device_label=stored.device_label,
target=f"{payload.action}:{payload.target}",
)
raise ValueError("stale passkey counter")
grant = await dashboard_auth.issue_step_up(
request.state.dashboard_session,
@@ -1686,9 +1697,19 @@ async def verify_passkey_authentication(
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
store.advance_counter,
stored.credential_id,
expected=stored.sign_count,
new=verified.new_sign_count,
)
if not updated:
await asyncio.to_thread(
_security_event_store().record,
"passkey_counter_anomaly",
method="passkey",
device_label=stored.device_label,
target="sign_in:dashboard",
)
raise ValueError("stale passkey counter")
await dashboard_auth.revoke_managed_session(stored.management_id)
signed, session = await asyncio.to_thread(
@@ -1788,6 +1809,10 @@ async def push_status(request: Request):
async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
if not _push_configuration().enabled:
raise HTTPException(status_code=503, detail="Push notifications are not configured")
try:
await validate_public_push_endpoint(payload.endpoint)
except UnsafePushEndpoint as error:
raise HTTPException(status_code=422, detail=str(error)) from error
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
@@ -3432,6 +3457,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/unread")
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mark_notification_unread(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Restoring the update timed out. Retry Undo."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update could not be restored. Retry Undo."},
status_code=503,
)
return JSONResponse({"id": thread_id, "status": "unread"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
+17 -4
View File
@@ -225,13 +225,26 @@ class PasskeyStore:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
def advance_counter(
self, credential_id: bytes, *, expected: int, new: int
) -> bool:
if expected == 0 and new == 0:
comparison = "sign_count = 0"
elif new > expected:
comparison = "sign_count = ?"
else:
return False
try:
with self._connect() as connection:
parameters = (
(new, credential_id)
if expected == 0 and new == 0
else (new, credential_id, expected)
)
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
f"UPDATE passkey_credentials SET sign_count = ? "
f"WHERE credential_id = ? AND {comparison}",
parameters,
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
+62
View File
@@ -0,0 +1,62 @@
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit
class UnsafePushEndpoint(ValueError):
"""Raised when a Web Push endpoint could reach a non-public service."""
Resolver = Callable[[str, int], Awaitable[list[str]]]
async def _resolve(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(
host,
port,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
return sorted({record[4][0] for record in records})
async def validate_public_push_endpoint(
endpoint: str,
*,
resolver: Resolver = _resolve,
timeout_seconds: float = 2.0,
) -> str:
"""Return a canonical public HTTPS push endpoint or fail closed."""
parsed = urlsplit(endpoint)
try:
port = parsed.port
except ValueError as error:
raise UnsafePushEndpoint(
"Endpoint must be a canonical public Web Push service URL"
) from error
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or port not in {None, 443}
):
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
try:
addresses = await asyncio.wait_for(
resolver(parsed.hostname, port or 443),
timeout=max(0.01, timeout_seconds),
)
except (OSError, asyncio.TimeoutError) as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
try:
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
except ValueError as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
if not public:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
return endpoint
+27 -1
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass
from typing import Awaitable, Callable
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@dataclass(frozen=True)
@@ -22,8 +23,12 @@ class PushConfiguration:
async def send_web_push(
subscription: dict, payload: str, configuration: PushConfiguration
) -> None:
import requests
from pywebpush import webpush
session = requests.Session()
session.max_redirects = 0
await asyncio.to_thread(
webpush,
subscription_info=subscription,
@@ -31,6 +36,8 @@ async def send_web_push(
vapid_private_key=configuration.private_key,
vapid_claims={"sub": configuration.subject},
ttl=300,
timeout=10,
requests_session=session,
)
@@ -45,6 +52,7 @@ async def dispatch_unread_updates(
send_timeout_seconds: float = 10.0,
max_concurrency: int = 8,
max_individual_notifications: int = 3,
endpoint_validator: Callable[[str], Awaitable[str]] | None = None,
) -> int:
if not configuration.enabled:
return 0
@@ -90,6 +98,15 @@ async def dispatch_unread_updates(
async with semaphore:
if ownership_lost.is_set():
return 0
validate_endpoint = endpoint_validator
if validate_endpoint is None and send is None:
validate_endpoint = validate_public_push_endpoint
try:
if validate_endpoint is not None:
await validate_endpoint(delivery.subscription["endpoint"])
except UnsafePushEndpoint:
await asyncio.to_thread(store.delete_session, delivery.session_id)
return 0
count = 0
digest_pending = set(delivery.digest_revisions)
new_revisions = tuple(
@@ -202,8 +219,17 @@ async def dispatch_unread_updates(
count += 1
return count
async def dispatch_device_safely(delivery) -> int:
try:
return await dispatch_device(delivery)
except Exception:
# Device-specific validation, persistence, or provider failures
# must not cancel healthy siblings. Leave any uncheckpointed
# revisions unseen so a later poll can retry them.
return 0
counts = await asyncio.gather(
*(dispatch_device(delivery) for delivery in deliveries)
*(dispatch_device_safely(delivery) for delivery in deliveries)
)
return sum(counts)
finally:
+3 -2
View File
@@ -1420,5 +1420,6 @@ async def test_dashboard_offers_explicit_account_bound_delivery_receipt_opt_in()
assert "await Notification.requestPermission()" in html
assert "backgroundIssueSync.setReceiptPreference(confirmedOwnerLogin, enabled)" in html
assert "await backgroundIssueSync.getReceiptPreference(confirmedOwnerLogin)" in html
assert "window.addEventListener('hashchange', openDeliveryReceiptRoute);" in html
assert "if (window.location.hash !== '#/my-work/drafts') return;" in html
assert "onQueue: openWorkQueueRoute" in html
assert "workRoute.queue(filter)" in html
assert "openDeliveryReceiptRoute" not in html
+1 -1
View File
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
assert '.update-reply-actions button { min-height:44px;' in html
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v97" in worker
+96
View File
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
options = await client.post("/api/v1/passkeys/authentication/options")
denied = await client.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
events = main._security_event_store().list(limit=10).events
assert denied.status_code == 401
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
assert events[0].device_label == "Phone"
assert events[0].target == "sign_in:dashboard"
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
options = await client.post(
"/api/v1/passkeys/authorization/options",
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
headers=headers,
)
denied = await client.post(
"/api/v1/passkeys/authorization/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"action": "merge_pull",
"target": "stackchain/dashboard#42",
},
headers=headers,
)
events = main._security_event_store().list(limit=10).events
assert options.status_code == 200
assert denied.status_code == 401
assert "grant" not in denied.json()
assert events[0].kind == "passkey_counter_anomaly"
assert events[0].target == "merge_pull:stackchain/dashboard#42"
@pytest.mark.anyio
async def test_passkey_options_are_source_limited_before_challenge_generation(
access_control, monkeypatch
+10 -5
View File
@@ -348,7 +348,8 @@ process.stdout.write(JSON.stringify(state));
assert result["deletedAtIdle"] == []
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["remaining"] == ["gitea.preference"]
assert result["replaced"] == [
@@ -373,7 +374,8 @@ process.stdout.write(JSON.stringify(state));
assert result["valid"] is False
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
@@ -503,7 +505,8 @@ process.stdout.write(JSON.stringify(state));
]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -525,7 +528,8 @@ process.stdout.write(JSON.stringify(state));
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
@@ -620,7 +624,8 @@ process.stdout.write(JSON.stringify(state));
assert "stackchain.private" in result["removed"]
assert result["remaining"] == ["gitea.preference"]
assert result["deletedDatabases"] == [
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
assert result["assigned"] == "/dashboard/login"
+1 -1
View File
@@ -313,7 +313,7 @@ async def test_mobile_dashboard_renders_delivery_center_separately_and_retries_w
assert 'Sending <strong data-delivery-count="sending">' in html
assert 'Needs attention <strong data-delivery-count="attention">' in html
assert 'Authorize <strong data-delivery-count="authorization">' in html
assert "const deliveryCenter = draftInbox.partition(lastDrafts);" in html
assert "const deliveryCenter = draftInbox.partition(displayedDrafts);" in html
assert "await Promise.all([issueOutbox.flush(activeFlushLogin), authoredOutbox.flush(activeFlushLogin)])" in html
assert "deliveryCenter.retryable.length" in html
assert "item.status === 'sending' ? 'Sending'" in html
+14 -2
View File
@@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 95 * 1024
assert len(first.runtime_gzip_bytes) <= 96 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
@@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
shell_block, optional_block = first.service_worker_source.split(
"const OPTIONAL_FEATURES = [", 1
)
optional_block = optional_block.split("];", 1)[0]
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
for name, bundle in first.feature_bundles.items():
if name == "today-timer":
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
else:
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
assert f"BASE + '{bundle.runtime_name}'" in optional_block
changed_frontend = tmp_path / "frontend"
shutil.copytree(FRONTEND, changed_frontend)
capture_source = changed_frontend / "create-issue-sheet.js"
@@ -175,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v93';",
"const CACHE = 'stackchain-dashboard-shell-v97';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+52
View File
@@ -744,3 +744,55 @@ async def test_mobile_dashboard_queues_offline_captures_and_exposes_outbox_actio
assert 'Needs attention' in html
assert "issueOutbox.discard(item.outbox_id)" in html
assert '.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr));' in html
def test_issue_outbox_reuses_the_original_operation_when_a_source_draft_is_retried():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'draft-op-' + (++sequence),
}});
const first = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
const retried = outbox.enqueue({{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-7'}});
process.stdout.write(JSON.stringify({{first,retried,items:outbox.list(),sequence}}));
"""
output = run_node(script)
assert output["sequence"] == 1
assert len(output["items"]) == 1
assert output["first"]["id"] == output["retried"]["id"] == "draft-op-1"
assert output["items"][0]["operationId"] == "draft-op-1"
assert output["items"][0]["sourceCaptureId"] == "capture-7"
def test_durable_source_draft_retry_reconciles_without_allocating_another_operation():
script = f"""
const createIssueOutbox = require({json.dumps(str(OUTBOX))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
let sequence = 0; const reconciliations = []; let syncRequests = 0;
const outbox = createIssueOutbox({{
storage, getOwnerLogin:()=> 'timmy', createOperationId:()=> 'durable-' + (++sequence),
backgroundSync:{{
reconcile:async items=>reconciliations.push(items.map(item=>item.operationId)),
requestSync:async()=>{{ syncRequests += 1; }},
}},
}});
(async()=>{{
const draft={{repository:'o/r',title:'Draft work',body:'Context',sourceCaptureId:'capture-8'}};
const first=await outbox.enqueueDurably(draft);
const retried=await outbox.enqueueDurably(draft);
process.stdout.write(JSON.stringify({{first,retried,reconciliations,syncRequests,items:outbox.list()}}));
}})();
"""
output = run_node(script)
assert "reused" not in output["first"]
assert output["retried"]["reused"] is True
assert output["reconciliations"] == [["durable-1"], ["durable-1"]]
assert output["syncRequests"] == 2
assert len(output["items"]) == 1
+1 -1
View File
@@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v97" in worker
+5 -1
View File
@@ -20,6 +20,7 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
("#issue-sheet .issue-sheet-panel", ".issue-comment-composer", "#issue-comment", "#send-issue-comment", "#issue-comment-status"),
("#pull-sheet .pull-sheet-panel", ".pull-comment-composer", "#pull-comment", "#send-pull-comment", "#pull-comment-status"),
("#update-sheet .update-sheet-panel", ".update-reply", "#update-reply", "#send-update-reply", "#update-reply-status"),
("#review-sheet .review-sheet-panel", "#review-inline-composer", "#review-inline-body", "#save-inline-comment", "#review-submit-status"),
]:
for selector in (panel, workspace, composer, submit, status):
assert repr(selector) in dashboard
@@ -27,10 +28,13 @@ def test_all_text_entry_flows_are_wired_to_keyboard_safe_panels():
assert "workspace:qs('#create-issue-form')" in dashboard
assert "focusWithin:true" in dashboard
assert "mobileComposerViewport.close(qs('.create-issue-panel'))" in dashboard
assert "mobileComposerViewport.close(qs('#review-sheet .review-sheet-panel'))" in dashboard
assert "height:var(--composer-viewport-height,100dvh)" in css
assert "top:var(--composer-viewport-top,0px)" in css
assert ".composer-keyboard-active" in css
assert ".create-issue-panel.composer-keyboard-active" in css
assert ".review-sheet-panel.composer-keyboard-active" in css
assert ".composer-keyboard-active .review-inline-composer" in css
assert ".composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px;" in css
@@ -41,7 +45,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v97" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+1 -1
View File
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v93" in worker
assert "stackchain-dashboard-shell-v97" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+1 -1
View File
@@ -69,4 +69,4 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert "header { min-height:56px; max-height:64px;" in html
assert ".work-settings:not([open]) > .work-settings-panel { display:none; }" in html
assert ".app-menu:not([open]) > .app-menu-panel { display:none; }" in html
assert ".my-work-actions button { min-height:44px;" in html
assert ".my-work-actions { display:none; }" in html
+176 -15
View File
@@ -9,21 +9,24 @@ from tests.dashboard_bundle import dashboard
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queue-launcher.js"
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2}};
const state = {{active:true, resumable:true, today:2, eligible:3}};
const calls = [];
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
getEligibleCount: () => state.eligible,
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
planToday: () => calls.push('plan'),
openFallback: () => calls.push('fallback'),
}});
const modes = [];
@@ -31,6 +34,7 @@ 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());
state.eligible = 0; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(
@@ -39,8 +43,8 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "resume", "start", "work"],
"calls": ["continue", "resume", "start", "fallback"],
"modes": ["continue", "resume", "start", "plan", "work"],
"calls": ["continue", "resume", "start", "plan", "fallback"],
}
@@ -113,6 +117,40 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const calls = [];
const makeButton = () => ({{
attributes: {{}},
setAttribute(name, value) {{ this.attributes[name] = value; }},
removeAttribute(name) {{ delete this.attributes[name]; }},
}});
const buttons = {{work:makeButton(), drafts:makeButton()}};
const dock = createDock({{
nav:{{}}, buttons,
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
}});
const selected = dock.select('drafts');
process.stdout.write(JSON.stringify({{
selected, calls,
work:buttons.work.attributes['aria-current'] || null,
drafts:buttons.drafts.attributes['aria-current'] || null,
}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"selected": True,
"calls": [],
"work": None,
"drafts": "page",
}
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
@@ -125,9 +163,11 @@ dock.updateWork('resume', 0);
const resuming = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('start', 0);
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('plan', 0);
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('work', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting,
continuing, resuming, starting, planning,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
@@ -143,6 +183,7 @@ process.stdout.write(JSON.stringify({{
},
"resuming": {"text": "Resume", "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"planning": {"text": "Plan", "label": "Plan Today"},
"fallback": {"text": "Work", "label": "Work"},
}
@@ -234,6 +275,126 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
class FakeElement {{
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
removeAttribute(name) {{ delete this.attributes[name]; }}
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const queues = new FakeElement();
const sheet = new FakeElement(); sheet.open = false;
sheet.showModal = function () {{ this.open = true; }};
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
const dock = createDock({{
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, attention:1, update:5, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.update.click();
const closedAfterSelect = !sheet.open;
queues.click(); rows.recaps.click();
const closedAfterUtility = !sheet.open;
queues.click(); close.click();
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
updateLabel:rows.update.attributes['aria-label'],
queueFocuses:queues.focuses,
columnState:nav.attributes['data-attention'] || null,
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": True,
"closedAfterSelect": True,
"closedAfterUtility": True,
"selected": ["update"],
"utilities": ["recaps:trigger"],
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
"badge": "10",
"badgeLabel": "Queues, 10 items",
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
"updateLabel": "Updates, 5 unread conversations",
"queueFocuses": 3,
"columnState": None,
}
def test_mobile_queue_launcher_opens_first_actionable_item_after_selecting_queue():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
const launcher = createLauncher({{
selectFilter: name => calls.push('filter:' + name),
firstAction: name => name === 'attention' ? {{click() {{ calls.push('open:first'); }}}} : null,
announce: message => calls.push('announce:' + message),
}});
const opened = launcher.open('attention');
const empty = launcher.open('later');
process.stdout.write(JSON.stringify({{opened, empty, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"opened": "opened",
"empty": "empty",
"calls": [
"filter:attention",
"open:first",
"filter:later",
"announce:No deferred work is ready to open.",
],
}
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
html = await dashboard()
assert 'data-mobile-task="queues"' in html
assert 'id="mobile-queue-sheet"' in html
assert 'aria-labelledby="mobile-queue-heading"' in html
assert 'data-mobile-queue="today"' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
assert 'data-mobile-queue="later"' in html
assert 'data-mobile-queue="draft"' in html
assert 'data-mobile-queue="recaps"' in html
assert '<strong>Recaps</strong><small>History</small>' in html
assert 'id="mobile-queue-count"' in html
assert 'mobileTaskDock.updateQueues(counts)' in html
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
assert '.mobile-queue-sheet' in html
assert '.my-work-actions { display:none;' in html
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
assert '.mobile-task-dock[data-attention="true"]' not in html
@pytest.mark.anyio
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
html = await dashboard()
@@ -244,9 +405,11 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "isTodayActive: () => workSession.checkpointed()" in html
assert "isTodayResumable: () => workSession.resumable()" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "getEligibleCount: () => activeMyWork.length" in html
assert "continueToday: continueTodaySession" in html
assert "resumeToday: resumeTodaySession" in html
assert "startToday: startTodaySession" in html
assert "planToday: () => openPlanToday(mobileTaskButtons.work)" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@@ -259,27 +422,25 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
for task in ("work", "attention", "find", "new", "search", "drafts"):
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
for task in ("work", "find", "new", "search", "queues"):
assert f'data-mobile-task="{task}"' in html
assert 'id="mobile-draft-count"' in html
assert 'id="mobile-queue-count"' in html
assert 'data-work-filter="attention"' in html
assert 'id="mobile-attention-count"' in html
assert '.mobile-task-dock { display:none;' in html
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
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: () => mobileWorkEntry.open()" in html
assert "attention: openMobileAttention" in html
assert "qs('[data-work-filter=\"attention\"]').click()" in html
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
assert "find: () => qs('#find-work').click()" in html
assert "new: () => qs('#new-issue').click()" in html
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 "queues: () => {}" in html
assert "mobileTaskDock.updateQueues(counts)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
@@ -316,7 +477,7 @@ async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
assert 'class="attention-interruption" id="attention-interruption"' in html
assert 'id="return-to-today" type="button">Return to Today</button>' in html
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
assert 'timer.returnFromAttention()' in html
assert "workSession.reopen(item)" in html
assert "attentionInterruption.hidden = !pending" in html
+355 -1
View File
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
@@ -24,6 +25,104 @@ 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"
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
def test_queue_finder_matches_repository_number_and_title_without_reordering():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
{{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
{{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
{{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
];
process.stdout.write(JSON.stringify({{
repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
number:work.findQueueItems(items, '#42').map(item => item.key),
title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
clear:work.findQueueItems(items, ' ').map(item => item.key),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"repo": ["stackchain/api#42"],
"number": ["stackchain/api#42", "other/repo#42"],
"title": ["stackchain/web#7"],
"clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
}
@pytest.mark.anyio
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
html = await dashboard()
assert '<form class="queue-finder" id="queue-finder" role="search">' in html
assert 'id="queue-find-input"' in html
assert 'aria-label="Find in active work queue"' in html
assert 'id="clear-queue-find"' in html
assert 'id="search-older-work"' in html
assert 'id="queue-find-status" role="status" aria-live="polite"' in html
assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
mobile = html.index('@media (max-width: 600px)')
assert '.queue-finder { position:sticky;' in html[mobile:]
assert "findQueueItems(queueItems, queueFindQuery)" in html
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
function storage(fail = false) {{
const values = new Map();
return {{
getItem:key => values.get(key) || null,
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
removeItem:key => values.delete(key),
}};
}}
const first = {{kind:'issue',repository:'o/r',number:1}};
const second = {{kind:'pull',repository:'o/r',number:2}};
const third = {{kind:'issue',repository:'o/r',number:3}};
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
const admitted = today.addMany([first, first, second]);
const existing = today.addMany([second, third]);
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
"full": {"status": "full", "ids": []},
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
"broken": {"status": "unavailable", "ids": []},
"brokenIds": [],
}
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
script = f"""
const create = require({json.dumps(str(WORK_SELECTION))});
const selection = create({{limit:3}});
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
selection.start(); selection.select(issue); selection.select(pull);
const before = selection.snapshot();
selection.retain([pull]);
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
"issueId": "issue:o/r:1:",
}
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
@@ -395,6 +494,117 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/updates'}};
const calls = [];
const stack = ['#/my-work/updates'];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: queue => calls.push(['queue', queue]),
onOpen: item => calls.push(['open', item.notification_id]),
onClose: () => calls.push(['close']),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([]);
controller.open({{kind:'update', notification_id:42}});
controller.close();
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["queue", "update"],
["open", 42],
["close"],
["queue", "update"],
],
"hash": "#/my-work/updates",
"parsed": {"kind": "queue", "filter": "update"},
}
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'attention', 'updates', 'later', 'drafts'];
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
const listeners = {{}};
const location = {{hash:'#/my-work/later'}};
const calls = [];
const stack = ['#/my-work/later'];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: queue => calls.push(['queue', queue]),
onOpen: item => calls.push(['open', item.number]),
onClose: () => calls.push(['close']),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]);
controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}});
controller.close();
controller.queue('draft');
process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"parsed": [
["today", {"kind": "queue", "filter": "today"}],
["attention", {"kind": "queue", "filter": "attention"}],
["updates", {"kind": "queue", "filter": "update"}],
["later", {"kind": "queue", "filter": "later"}],
["drafts", {"kind": "queue", "filter": "draft"}],
],
"calls": [
["queue", "later"],
["open", 42],
["close"],
["queue", "later"],
["queue", "draft"],
],
"hash": "#/my-work/drafts",
}
def test_mobile_queue_routes_reject_unknown_or_nested_fragments():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
process.stdout.write(JSON.stringify([
routes.parse('#/my-work/tomorrow'),
routes.parse('#/my-work/today/extra'),
routes.parse('#/my-work/drafts/1'),
]));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [None, None, None]
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -507,6 +717,10 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
assert "onQueue: openWorkQueueRoute" in html
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
assert "workRoute.queue(filter)" in html
assert "openDeliveryReceiptRoute" not in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -1227,6 +1441,59 @@ process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
}
def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const changes = [];
let writes = 0;
let fail = false;
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
removeItem:key => values.delete(key),
}};
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
const store = createLaterWork({{
storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
setTimer:() => 1, clearTimer:() => {{}},
onChange:(...change) => changes.push(change),
}});
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
const saved = store.partition([first, second]);
const beforeFailure = JSON.stringify(store.read());
fail = true;
const unavailable = store.deferMany([
{{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
{{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
], new Date('2026-08-10T09:00:00Z'));
process.stdout.write(JSON.stringify({{
deferred, unavailable, writes, changes,
later:saved.later.map(item => [item.notification_id,item.deferred_until]),
unchanged:beforeFailure === JSON.stringify(store.read()),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"deferred": "deferred",
"unavailable": "unavailable",
"writes": 2,
"changes": [
["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
],
"later": [
[91, "2026-08-09T09:00:00.000Z"],
[92, "2026-08-09T09:00:00.000Z"],
],
"unchanged": True,
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
@@ -4076,6 +4343,58 @@ const reader = buildMyWork.createNotificationReader({{
assert output["results"][1] is False
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
script = f"""
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
const states = [];
const statuses = [];
const calls = [];
let attempts = 0;
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
const undo = createNotificationUndo({{
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
onItems: items => states.push(items.map(value => value.notification_id)),
onStatus: status => statuses.push(status),
}});
(async () => {{
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
const first = undo.run();
const duplicate = undo.run();
const firstResults = await Promise.all([first, duplicate]);
const retry = await undo.run();
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42, 42]
assert output["states"] == [[42, 43]]
assert output["firstResults"] == [False, False]
assert output["retry"] is True
assert output["statuses"] == [
"repo#7 marked read. Undo?",
"Restoring repo#7…",
"Could not restore repo#7. Retry Undo.",
"Restoring repo#7…",
"repo#7 is unread again.",
]
@pytest.mark.anyio
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
html = await dashboard()
assert 'id="notification-undo"' in html
assert 'id="undo-notification" type="button"' in html
assert "requestNotificationUnread" in html
assert "createDashboardNotificationUndo({" in html
assert ".notification-undo { position:fixed;" in html
assert "env(safe-area-inset-bottom)" in html
assert ".notification-undo button { min-height:44px;" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
@@ -4354,7 +4673,9 @@ reader.open(item, [item]).then(() => {{
)
output = json.loads(result.stdout)
assert output["result"] == {"items": [], "next": None}
assert output["result"] == {"items": [], "item": {
"kind": "update", "notification_id": 42, "has_update": True,
}, "next": None}
assert output["events"] == [
["status", "Marking update read…"],
["items", []],
@@ -5412,6 +5733,39 @@ async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_selected_updates_can_be_deferred_together_without_marking_them_read():
html = await dashboard()
assert 'id="defer-selected-today"' in html
assert 'id="defer-selected-tomorrow"' in html
assert 'laterWork.deferMany(selectedUpdates, until)' in html
assert "notificationSelection.cancel()" in html
assert "Selection unchanged." in html
assert "work stays unread and unchanged in Gitea" in html
assert '.my-work-bulk-actions { display:grid;' in html
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
@pytest.mark.anyio
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
html = await dashboard()
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert '<script src="static/work-selection.js"></script>' in html
assert 'id="select-work"' in html
assert 'id="batch-add-today"' in html
assert 'createWorkSelection({ limit: 50' in html
assert 'todayWork.addMany(selectedItems)' in html
assert 'laterWork.deferMany(selectedItems, until)' in html
assert 'data-select-work-id=' in html
assert 'Select work ' in html
assert "workSelectionState.count + ' selected'" in html
assert '.work-selector { min-height:44px;' in html
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
assert "BASE + 'static/work-selection.js'" in service_worker
@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()
+43
View File
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_unread(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
@@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
restored = []
async def mark_unread(thread_id):
restored.append(thread_id)
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/unread")
invalid = await client.patch("/api/v1/notifications/0/unread")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "unread"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert restored == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
+41
View File
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
).fetchall()
assert "source_hash" in columns
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
database = tmp_path / "passkeys.sqlite3"
first = PasskeyStore(database, clock=lambda: 1_000.0)
second = PasskeyStore(database, clock=lambda: 1_000.0)
first.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
assert first.get(b"phone-credential").sign_count == 5
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
tmp_path,
):
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
store.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
store.register(
credential_id=b"counterless-credential",
public_key=b"counterless-public-key",
sign_count=0,
device_label="Security key",
management_id="counterless-management-id",
)
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
+79 -1
View File
@@ -164,6 +164,77 @@ process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}
}
def test_plan_today_builds_ranked_ready_capacity_fit_without_saving():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = (number, reason) => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number, reason}});
const saved = [];
+const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: plan => saved.push(plan),
}});
const id = n => 'issue:stackchain/dashboard:' + n + ':';
planner.open([], [item(1, 'Due today'), item(2, 'Unread update'), item(3, 'Assigned to you'), item(4, 'Needs your review')], {{
capacity_minutes:90, estimates:{{[id(1)]:60,[id(2)]:45,[id(3)]:30,[id(4)]:20}}
}});
const build = planner.buildRecommendation({{
[id(1)]:{{status:'ready'}},
[id(2)]:{{status:'blocked', reason:'Waiting on API'}},
[id(3)]:{{status:'ready'}},
[id(4)]:{{status:'unverified'}},
}});
process.stdout.write(JSON.stringify({{build, snapshot:planner.snapshot(), saved}}));
""".replace("+const", "const")
result = run_node(script)
assert result["build"] == {
"selected": ["issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"],
"reasons": {
"issue:stackchain/dashboard:1:": "Due today",
"issue:stackchain/dashboard:3:": "Assigned to you",
},
"skipped": [
{"id": "issue:stackchain/dashboard:2:", "status": "blocked", "reason": "Waiting on API"},
{"id": "issue:stackchain/dashboard:4:", "status": "unverified", "reason": "Could not verify readiness"},
],
"needs_estimate": [],
}
assert result["snapshot"]["ids"] == [
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:3:"
]
assert result["snapshot"]["planned_minutes"] == 90
assert result["snapshot"]["remaining_minutes"] == 0
assert result["snapshot"]["build"] == result["build"]
assert result["saved"] == []
def test_plan_today_estimates_ready_omission_and_refits_without_another_readiness_check():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: () => true,
}});
const id = n => 'issue:stackchain/dashboard:' + n + ':';
planner.open([], [item(1), item(2)], {{capacity_minutes:60, estimates:{{[id(1)]:30}}}});
planner.buildRecommendation({{[id(1)]:{{status:'ready'}}, [id(2)]:{{status:'ready'}}}});
const before = planner.snapshot();
const estimated = planner.setEstimate(id(2), 30);
const after = planner.snapshot();
process.stdout.write(JSON.stringify({{before, estimated, after}}));
"""
result = run_node(script)
assert result["before"]["ids"] == ["issue:stackchain/dashboard:1:"]
assert result["before"]["build"]["needs_estimate"] == ["issue:stackchain/dashboard:2:"]
assert result["estimated"] is True
assert result["after"]["ids"] == [
"issue:stackchain/dashboard:1:", "issue:stackchain/dashboard:2:"
]
assert result["after"]["estimates"]["issue:stackchain/dashboard:2:"] == 30
assert result["after"]["remaining_minutes"] == 0
assert result["after"]["build"]["needs_estimate"] == []
def test_plan_today_preview_preserves_draft_scroll_and_adds_item_once_on_return():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
@@ -278,6 +349,12 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
assert 'id="plan-today-sheet" role="dialog"' in html
assert 'id="plan-today-capacity"' in html
assert 'id="plan-today-available"' in html
assert 'id="build-today-plan"' in html
assert 'id="plan-today-build-status" role="status" aria-live="polite"' in html
assert 'id="plan-today-estimates"' in html
assert 'Estimate to finish your plan' in html
assert 'data-plan-missing-estimate="' in html
assert '.plan-today-estimates input { min-height:44px;' in html
assert 'inputmode="numeric" min="15" max="1440"' in html
assert 'data-plan-estimate="' in html
assert 'aria-label="Estimate for ' in html
@@ -333,6 +410,7 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
def test_plan_today_controller_is_available_in_the_offline_shell():
source = SERVICE_WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/plan-today.js'" in source
assert "BASE + 'static/plan-today-readiness.js'" in source
assert "BASE + 'static/plan-today-preview.js'" in source
+94
View File
@@ -0,0 +1,94 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
READINESS = Path(__file__).parents[1] / "frontend" / "plan-today-readiness.js"
def run_node(script: str) -> dict:
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(result.stdout)
def test_plan_readiness_bounds_concurrency_and_preserves_ranked_order():
script = f"""
const createReadiness = require({json.dumps(str(READINESS))});
let active = 0;
let peak = 0;
const completed = [];
const delays = {{1:40, 2:5, 3:20, 4:1, 5:10, 6:2}};
const readiness = createReadiness({{
concurrency:3,
identity:item => 'issue:' + item.number,
inspect:async item => {{
active += 1;
peak = Math.max(peak, active);
await new Promise(resolve => setTimeout(resolve, delays[item.number]));
completed.push(item.number);
active -= 1;
return item.number === 4 ? {{status:'blocked', reason:'Waiting'}} : {{status:'ready'}};
}},
}});
readiness.run([1,2,3,4,5,6].map(number => ({{kind:'issue', number}}))).then(result => {{
process.stdout.write(JSON.stringify({{peak, completed, entries:Object.entries(result)}}));
}});
"""
result = run_node(script)
assert result["peak"] == 3
assert result["completed"] != [1, 2, 3, 4, 5, 6]
assert result["entries"] == [
["issue:1", {"status": "ready"}],
["issue:2", {"status": "ready"}],
["issue:3", {"status": "ready"}],
["issue:4", {"status": "blocked", "reason": "Waiting"}],
["issue:5", {"status": "ready"}],
["issue:6", {"status": "ready"}],
]
def test_plan_readiness_supersedes_stale_builds_and_normalizes_real_failures():
script = f"""
const createReadiness = require({json.dumps(str(READINESS))});
let releaseFirst;
const firstGate = new Promise(resolve => {{ releaseFirst = resolve; }});
const readiness = createReadiness({{
concurrency:2,
identity:item => 'issue:' + item.number,
inspect:async item => {{
if (item.number === 1) await firstGate;
if (item.number === 3) throw new Error('upstream details');
return {{status:'ready'}};
}},
}});
const stale = readiness.run([{{number:1}}, {{number:2}}]);
const current = readiness.run([{{number:3}}, {{number:4}}]);
current.then(currentResult => {{
releaseFirst();
stale.then(staleResult => process.stdout.write(JSON.stringify({{staleResult, currentResult}})));
}});
"""
assert run_node(script) == {
"staleResult": None,
"currentResult": {
"issue:3": {"status": "unverified"},
"issue:4": {"status": "ready"},
},
}
@pytest.mark.anyio
async def test_dashboard_uses_bounded_supersedable_readiness_for_build_my_today():
html = await dashboard()
assert '<script src="static/plan-today-readiness.js"></script>' in html
assert "const planTodayReadiness = createPlanTodayReadiness({" in html
assert "concurrency:3" in html
assert "const checks = await planTodayReadiness.run(candidates);" in html
assert "if (!checks) return;" in html
assert "planTodayReadiness.cancel();" in html
assert "Promise.all(candidates.map" not in html
+1
View File
@@ -46,6 +46,7 @@ const clear = createPrivateDeviceDataPurger({{
assert state["databases"] == [
"stackchain-background-outbox-v1",
"stackchain-offline-work-v2",
"stackchain-unfiled-captures-v1",
]
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
+37
View File
@@ -0,0 +1,37 @@
import pytest
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@pytest.mark.anyio
@pytest.mark.parametrize(
"address",
["127.0.0.1", "::1", "10.0.0.4", "169.254.169.254", "fc00::1"],
)
async def test_push_endpoint_rejects_every_non_public_resolved_address(address):
async def resolve(_host, _port):
return [address]
with pytest.raises(UnsafePushEndpoint, match="public Web Push service"):
await validate_public_push_endpoint(
"https://push.example/device", resolver=resolve
)
@pytest.mark.anyio
@pytest.mark.parametrize(
"endpoint",
[
"http://push.example/device",
"https://user@push.example/device",
"https://push.example:8443/device",
"https://push.example:not-a-port/device",
"https://push.example/device#fragment",
],
)
async def test_push_endpoint_rejects_noncanonical_authorities_without_resolving(endpoint):
async def must_not_resolve(_host, _port):
raise AssertionError("invalid URL reached DNS")
with pytest.raises(UnsafePushEndpoint, match="canonical public Web Push"):
await validate_public_push_endpoint(endpoint, resolver=must_not_resolve)
+136
View File
@@ -2,6 +2,7 @@ import json
import asyncio
import os
import sqlite3
import time
from types import SimpleNamespace
import httpx
@@ -10,6 +11,15 @@ import pytest
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import PushConfiguration, dispatch_unread_updates
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint
@pytest.fixture(autouse=True)
def resolve_test_push_endpoints_publicly(monkeypatch):
async def accept(endpoint):
return endpoint
monkeypatch.setattr(main, "validate_public_push_endpoint", accept)
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
@@ -508,6 +518,32 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
assert store.is_subscribed("session-a") is False
@pytest.mark.anyio
async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contacting_it(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("unsafe-device", {
"endpoint": "https://push.example/rebound",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
contacted = []
async def unread():
return {"items": [{"id": 8}]}
async def reject_rebound(_endpoint):
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
async def send(subscription, _payload):
contacted.append(subscription["endpoint"])
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(
store, config, unread, send, endpoint_validator=reject_rebound
) == 0
assert contacted == []
assert store.is_subscribed("unsafe-device") is False
@pytest.mark.anyio
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -532,6 +568,75 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
assert store.claim_unseen({8})[0].session_id == "session-a"
@pytest.mark.anyio
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
path = tmp_path / "push.sqlite3"
store = PushSubscriptionStore(path)
competitor = PushSubscriptionStore(path)
for session_id in ("failing-device", "healthy-device"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
healthy_started = asyncio.Event()
release_healthy = asyncio.Event()
sent = []
async def unread():
return {"items": [{"id": 8, "updated_at": "r1"}], "complete": True}
async def validate(endpoint):
if endpoint.endswith("failing-device"):
raise TimeoutError("endpoint validation unavailable")
return endpoint
async def send(subscription, _payload):
sent.append(subscription["endpoint"])
healthy_started.set()
await release_healthy.wait()
config = PushConfiguration("public", "private", "mailto:ops@example.com")
dispatch = asyncio.create_task(dispatch_unread_updates(
store,
config,
unread,
send,
endpoint_validator=validate,
))
await asyncio.wait_for(healthy_started.wait(), timeout=0.2)
await asyncio.sleep(0)
assert dispatch.done() is False
assert competitor.acquire_dispatch_lease(
"competing-worker", now=time.time(), lease_seconds=60
) is False
release_healthy.set()
assert await dispatch == 1
remaining = store.claim_unseen({8: "r1"})
assert [(item.session_id, item.thread_ids) for item in remaining] == [
("failing-device", (8,))
]
retried = []
async def validate_recovered(endpoint):
return endpoint
async def send_retry(subscription, _payload):
retried.append(subscription["endpoint"])
assert await dispatch_unread_updates(
store,
config,
unread,
send_retry,
endpoint_validator=validate_recovered,
) == 1
assert retried == ["https://push.example/failing-device"]
assert sent == ["https://push.example/healthy-device"]
@pytest.mark.anyio
async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -722,6 +827,37 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
assert (await main.push_status(request))["subscribed"] is False
@pytest.mark.anyio
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(
main, "_push_configuration",
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
)
async def management_id(_session):
return "unsafe-device"
async def reject_private(_endpoint):
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main, "validate_public_push_endpoint", reject_private)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.PushSubscriptionPayload(
endpoint="https://push.example/device-a",
keys={"p256dh": "public-key", "auth": "auth-secret"},
)
with pytest.raises(main.HTTPException) as rejected:
await main.subscribe_push(payload, request)
assert rejected.value.status_code == 422
assert rejected.value.detail == "Endpoint must resolve to a public Web Push service"
assert store.is_subscribed("unsafe-device") is False
@pytest.mark.anyio
async def test_subscription_baselines_every_revision_from_the_complete_snapshot(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+39
View File
@@ -47,3 +47,42 @@ const boundary={{
"hidden": False,
"focused": True,
}
def test_counter_anomaly_activity_gives_passkey_remediation_guidance():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const element=()=>({{
hidden:true, disabled:false, textContent:'', children:[], className:'',
addEventListener(){{}}, focus(){{}}, replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},
}});
const ids={{
'active-devices':element(), 'active-devices-sheet':element(),
'active-devices-list':element(), 'active-devices-status':element(),
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
'enroll-passkey':element(), 'security-activity-list':element(),
'security-activity-status':element(), 'load-more-security-activity':element(),
'close-active-devices':element(),
}};
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
const boundary={{
listActiveDevices:async()=>[], listPasskeys:async()=>[],
listSecurityEvents:async()=>({{events:[{{
kind:'passkey_counter_anomaly', device_label:'Phone', method:'passkey',
target:'sign_in:dashboard', status:'completed', created_at:1,
}}],authentication_alerts:[],next_cursor:null}}),
}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
const row=ids['security-activity-list'].children[0];
console.log(JSON.stringify(row.children.map(child=>child.textContent)));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
title, detail = json.loads(completed.stdout)
assert title == "Passkey counter anomaly"
assert "Remove and re-enroll this passkey" in detail
+77 -12
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -24,6 +24,10 @@ storedResponses.set(
);
const cache = {{
addAll: async urls => {{ state.added = urls; }},
add: async url => {{
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
state.individuallyAdded.push(url);
}},
match: async request => {{
const key = String(request.url || request);
if (storedResponses.has(key)) return storedResponses.get(key).clone();
@@ -58,6 +62,12 @@ const context = {{
}},
}},
importScripts: () => {{}},
indexedDB: {{deleteDatabase:name=>{{
state.deletedDatabases.push(name);
const request={{}};
queueMicrotask(()=>request.onsuccess?.());
return request;
}}}},
caches: {{
open: async () => cache,
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
@@ -83,7 +93,7 @@ const context = {{
}},
}};
vm.createContext(context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
async function dispatch(name, request) {{
let pending;
let response;
@@ -135,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -144,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/dashboard.js'" in source
def test_offline_review_next_ships_today_completion_atomically():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -159,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -167,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/later-picker.js'" in source
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -183,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -244,6 +254,9 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert result["state"]["clientMessages"] == [
{"type": "stackchain-session-revoked"}
@@ -331,6 +344,9 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
)
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["replies"] == [{"ok": True}]
@@ -638,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
def test_queue_today_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -668,6 +684,8 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
"/dashboard/static/issue-outbox.js",
"/dashboard/static/authored-outbox.js",
@@ -677,7 +695,9 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/offline-work.js",
"/dashboard/static/offline-today.js",
"/dashboard/static/my-work.js",
"/dashboard/static/notification-undo.js",
"/dashboard/static/card-planning.js",
"/dashboard/static/work-selection.js",
"/dashboard/static/today-work.js",
"/dashboard/static/today-timer.js",
"/dashboard/static/today-recap.js",
@@ -686,10 +706,12 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/comment-next.js",
"/dashboard/static/update-reply-read-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/update-follow-up.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/later-and-start.js",
@@ -711,6 +733,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
@@ -734,6 +757,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
assert result["deleted"] == ["stackchain-dashboard-old"]
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
result = run_worker_scenario(
"""
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
context.self.__testOptionalFeatures.push(
'/dashboard/feature-issue-capture-test.js',
'/dashboard/feature-security-center-test.js',
'/dashboard/feature-pull-workflow-test.js',
);
await dispatch('activate');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["claimed"] is True
assert result["individuallyAdded"] == [
"/dashboard/feature-issue-capture-test.js",
"/dashboard/feature-pull-workflow-test.js",
]
def test_warmed_optional_feature_is_served_from_cache_while_offline():
result = run_worker_scenario(
"""
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
state.cachedBody = 'cached feature';
state.failFetch = true;
const response = await dispatch('fetch', {
method: 'GET', mode: 'cors',
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
});
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
"""
)
assert result["body"] == "cached feature"
assert result["state"]["fetches"] == []
def test_offline_navigation_returns_cached_shell_for_share_target_url():
result = run_worker_scenario(
"""
@@ -773,6 +835,9 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
assert result["status"] == 401
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
assert result["state"]["outboxPurges"] == 1
assert result["state"]["deletedDatabases"] == [
"stackchain-unfiled-captures-v1"
]
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
assert "private cached dashboard" not in result["body"]
+1 -1
View File
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
def test_readiness_runtime_is_available_in_offline_shell():
service_worker = SERVICE_WORKER.read_text()
assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v97';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+1 -1
View File
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
def test_inflight_today_drain_ships_in_a_new_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v93" in source
assert "stackchain-dashboard-shell-v97" in source
assert "BASE + 'static/today-sync.js'" in source
+324 -8
View File
@@ -8,6 +8,7 @@ from tests.dashboard_bundle import dashboard
UNFILED = Path(__file__).parents[1] / "frontend" / "unfiled-captures.js"
DRAFT_SESSION = Path(__file__).parents[1] / "frontend" / "draft-filing-session.js"
def run_node(script: str):
@@ -15,7 +16,29 @@ def run_node(script: str):
return json.loads(result.stdout)
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
def test_draft_filing_session_navigates_stable_capture_ids_and_reconciles_removed_items():
script = f"""
const createDraftFilingSession = require({json.dumps(str(DRAFT_SESSION))});
let captures = [{{id:'newest'}}, {{id:'middle'}}, {{id:'oldest'}}];
const session = createDraftFilingSession({{list:()=>captures}});
const opened = session.start('middle');
const skipped = session.next();
captures = [{{id:'newest'}}];
const afterFiled = session.removeCurrentAndNext('oldest');
const previous = session.previous();
process.stdout.write(JSON.stringify({{opened,skipped,afterFiled,previous}}));
"""
output = run_node(script)
assert output == {
"opened": {"id": "middle", "position": 2, "total": 3, "remaining": 1},
"skipped": {"id": "oldest", "position": 3, "total": 3, "remaining": 0},
"afterFiled": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
"previous": {"id": "newest", "position": 1, "total": 1, "remaining": 0},
}
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
@@ -32,25 +55,203 @@ const captures = createUnfiledCaptures({{
createId:()=>String(++sequence),
now:()=>1000 + sequence,
}});
for (let index = 1; index <= 22; index += 1) {{
for (let index = 1; index <= 20; index += 1) {{
captures.save({{title:'Note ' + index, body:'Context ' + index}});
}}
let fullError = '';
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
fullError = error.message;
}}
const beforeReplace = captures.list();
const oldest = captures.capacity().oldest;
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
const offline = captures.list();
const restored = createUnfiledCaptures({{
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
}}).list();
process.stdout.write(JSON.stringify({{offline, restored}}));
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
"""
output = run_node(script)
assert output["fullError"] == "Drafts full — nothing was deleted."
assert len(output["beforeReplace"]) == 20
assert output["beforeReplace"][-1]["title"] == "Note 1"
assert output["oldest"]["title"] == "Note 1"
assert len(output["offline"]) == 20
assert output["offline"][0]["title"] == "Note 22"
assert output["offline"][-1]["title"] == "Note 3"
assert output["offline"][0]["title"] == "Note 21"
assert output["offline"][-1]["title"] == "Note 2"
assert all(item["quarantined"] for item in output["offline"])
assert all(not item["quarantined"] for item in output["restored"])
assert output["restored"][0]["ownerLogin"] == "timmy"
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const deleted = [];
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{ deleted.push(id); blobs.delete(id); }},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
let mismatch = '';
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
catch (error) {{ mismatch = error.message; }}
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
process.stdout.write(JSON.stringify({{
mismatch, titles:captures.list().map(item=>item.title), deleted,
blobs:[...blobs.keys()], third:third.title,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"mismatch": "Drafts changed. Review them before replacing anything.",
"titles": ["Third", "Second"],
"deleted": ["1"],
"blobs": ["2", "3"],
"third": "Third",
}
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => {{
if (id === '1') throw new Error('IndexedDB delete failed');
blobs.delete(id);
}},
}};
(async () => {{
let id = 0;
const captures = createUnfiledCaptures({{
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>String(++id), now:()=>id,
}});
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
let error = '';
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume(original.id, 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Original"],
"blobs": ["1"],
"resumedTitle": "Original",
"resumedFilename": "original.png",
}
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "IndexedDB delete failed",
"titles": ["Keep this"],
"resumedTitle": "Keep this",
"resumedFilename": "proof.png",
}
def test_discard_metadata_write_failure_restores_attachment_and_draft():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
let failWrites = false;
const storage = {{
getItem:k=>values.get(k)||null,
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
await captures.save({{title:'Keep this',body:'Important',attachment:{{
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
}}}});
failWrites = true;
let error = '';
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
failWrites = false;
const resumed = await captures.resume('capture-1', 'timmy');
process.stdout.write(JSON.stringify({{
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
resumedFilename:resumed.attachment.filename,
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output == {
"error": "Storage write failed",
"titles": ["Keep this"],
"blobIds": ["capture-1"],
"resumedFilename": "proof.png",
}
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -65,6 +266,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
let mismatch = '';
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
const resumed = captures.resume(first.id, 'timmy');
captures.completeResume(first.id);
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
"""
output = run_node(script)
@@ -74,6 +276,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
const values = new Map();
const blobs = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
const attachmentStore = {{
put: async (id, attachment) => blobs.set(id, attachment),
get: async id => blobs.get(id) || null,
delete: async id => blobs.delete(id),
}};
(async () => {{
const captures = createUnfiledCaptures({{
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
createId:()=>'capture-1', now:()=>42,
}});
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
const listed = captures.list();
const resumed = await captures.resume(saved.id, 'timmy');
const beforeComplete = captures.list().length;
await captures.completeResume(saved.id);
process.stdout.write(JSON.stringify({{
listed, beforeComplete, afterComplete:captures.list().length,
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
blobRemoved:!blobs.has(saved.id),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
output = run_node(script)
assert output["listed"][0]["hasAttachment"] is True
assert "attachment" not in output["listed"][0]
assert output["beforeComplete"] == 1
assert output["afterComplete"] == 0
assert output["blobRemoved"] is True
assert output["resumed"] == {
"title": "Broken mobile layout",
"body": "At 320px",
"filename": "phone.png",
"contentType": "image/png",
"size": 5,
}
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
script = f"""
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
@@ -100,12 +348,80 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
assert '<script src="static/unfiled-captures.js"></script>' in html
assert 'id="save-unfiled-issue"' in html
assert 'Save for filing' in html
assert 'Save to Drafts' in html
assert "createUnfiledCaptures({" in html
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
assert "unfiledCaptures.save(captureDraft)" in html
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
assert "issueCapture.saveDraft(resumed)" in html
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
assert "mobileTaskDock.select('queues')" in html
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
assert "requestAnimationFrame(() =>" in html
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
assert "await dependencies.captures.resume(captureId, dependencies.getLogin())" in DRAFT_SESSION.read_text()
assert "await createIssueAttachmentController.serialize()" in html
assert "createUnfiledAttachmentStore()" in html
assert "dependencies.attachment[resumed.attachment ? 'restore' : 'clear'](resumed.attachment)" in DRAFT_SESSION.read_text()
assert "await unfiledCaptures.completeResume(rUC)" in html
assert "...(rUC ? { sourceCaptureId: rUC } : {})" in html
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
assert "dependencies.issueCapture.saveDraft(resumed)" in DRAFT_SESSION.read_text()
assert "item.kind === 'unfiled-issue'" in html
assert '.create-issue-actions button { min-height:44px;' in html
assert '.draft-card:focus-visible' in html
assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
assert '@media(max-width:320px)' in html
@pytest.mark.anyio
async def test_mobile_drafts_expose_a_safe_sequential_filing_session():
html = await dashboard()
assert '<script src="static/draft-filing-session.js"></script>' in html
assert 'id="draft-filing-session"' in html
assert 'id="draft-filing-progress"' in html
assert 'id="previous-draft"' in html
assert 'id="skip-draft"' in html
assert "dFS.start(item.capture_id)" in html
assert "await dFS.nextCapture()" in html
assert "await dFS.advance(fS.id)" in html
feature = DRAFT_SESSION.read_text()
assert "Draft ${state.position} of ${state.total}" in feature
assert "qs('#submit-new-issue').textContent = state.remaining ? 'File & next' : 'File final Draft'" in feature
assert '.draft-filing-session-actions button { min-height:44px;' in html
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom))' in html
@pytest.mark.anyio
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
html = await dashboard()
assert 'id="draft-capacity-sheet"' in html
assert 'Drafts full — nothing was deleted.' in html
assert 'id="review-full-drafts"' in html
assert 'id="replace-oldest-draft"' in html
assert 'id="keep-editing-draft"' in html
assert "showDraftCapacityDialog(unfiledCaptures)" in html
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
assert '.draft-capacity-actions button { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
html = await dashboard()
assert 'id="create-issue-heading">Capture work' in html
assert 'class="create-issue-filing" id="create-issue-filing" hidden' in html
assert 'id="save-unfiled-issue" type="button">Save to Drafts' in html
assert 'id="file-new-issue" type="button">File now' in html
assert "function setIssueFilingMode(enabled)" in html
assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html
assert "qs('#create-issue-title').focus();" in html
assert "qs('#file-new-issue').addEventListener('click'" in html
assert '.create-issue-capture-actions button { min-height:44px;' in html
assert '.create-issue-capture-actions[hidden] { display:none;' in html
attachment = html.index('class="create-issue-attachment"')
filing = html.index('class="create-issue-filing"')
assert attachment < filing
+198
View File
@@ -0,0 +1,198 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).parents[1]
UPDATE_FOLLOW_UP = ROOT / "frontend" / "update-follow-up.js"
CREATE_ISSUE_SHEET = ROOT / "frontend" / "create-issue-sheet.js"
def run_node(script: str) -> dict:
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True, timeout=10
)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_follow_up_derives_repository_editable_title_and_canonical_context():
script = f"""
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
const controller = createFollowUp();
process.stdout.write(JSON.stringify(controller.draft({{
repository: 'stackchain/stackchain-dashboard',
title: 'Fix mobile queue',
subject_type: 'Pull',
url: 'https://forge.example/git/stackchain/stackchain-dashboard/pulls/42',
latest_comment: {{ body: 'Please preserve the unread state. Ship this on mobile.' }}
}})));
"""
assert run_node(script) == {
"repository": "stackchain/stackchain-dashboard",
"title": "Follow up: Fix mobile queue",
"body": (
"Source: https://forge.example/git/stackchain/stackchain-dashboard/pulls/42\n\n"
"Latest context:\n> Please preserve the unread state. Ship this on mobile."
),
}
def test_follow_up_rejects_unsafe_repository_and_noncanonical_source_url():
script = f"""
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
const controller = createFollowUp();
process.stdout.write(JSON.stringify(controller.draft({{
repository: '../admin', title: '<script>alert(1)</script>',
url: 'javascript:alert(1)', latest_comment: {{body: 'x'.repeat(12000)}}
}})));
"""
output = run_node(script)
assert output["repository"] == ""
assert output["title"] == "Follow up: <script>alert(1)</script>"
assert "javascript:" not in output["body"]
assert len(output["body"]) <= 9500
def test_follow_up_continuation_is_account_bound_and_survives_reload():
script = f"""
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
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)
}};
let login = 'timmy';
const first = createFollowUp({{storage, getLogin:()=>login}});
const staged = first.stageSource({{
notification_id: 42, repository:'stackchain/stackchain-dashboard', number:665,
title:'Unread follow-up source', kind:'update', has_update:true
}});
const restored = createFollowUp({{storage, getLogin:()=>login}}).source();
login = 'alexander';
const isolated = createFollowUp({{storage, getLogin:()=>login}}).source();
process.stdout.write(JSON.stringify({{staged, restored, isolated}}));
"""
output = run_node(script)
assert output["staged"]["notification_id"] == 42
assert output["restored"] == output["staged"]
assert output["isolated"] is None
def test_follow_up_completion_admits_issue_before_durable_read_and_advances_once():
script = f"""
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
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 order = [];
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
(async () => {{
const result = await controller.complete({{
admit:async()=>{{ order.push('issue'); return {{item:{{id:'issue:1'}}}}; }},
queueRead:async id=>{{ order.push('read:' + id); return {{item:{{id:'read:42'}}}}; }},
advance:async source=>{{ order.push('next:' + source.notification_id); return true; }},
}});
process.stdout.write(JSON.stringify({{order, result, pending:controller.source()}}));
}})();
"""
output = run_node(script)
assert output == {
"order": ["issue", "read:42", "next:42"],
"result": {"admission": {"item": {"id": "issue:1"}}, "advanced": True},
"pending": None,
}
def test_follow_up_completion_preserves_source_when_issue_admission_fails():
script = f"""
const createFollowUp = require({json.dumps(str(UPDATE_FOLLOW_UP))});
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 order = [];
const controller = createFollowUp({{storage, getLogin:()=> 'timmy'}});
controller.stageSource({{notification_id:42, repository:'o/r', number:7, title:'Source'}});
(async () => {{
try {{
await controller.complete({{
admit:async()=>{{ order.push('issue'); throw new Error('disk full'); }},
queueRead:async()=>order.push('read'), advance:async()=>order.push('next'),
}});
}} catch (error) {{
process.stdout.write(JSON.stringify({{order, error:error.message, pending:controller.source()}}));
}}
}})();
"""
output = run_node(script)
assert output["order"] == ["issue"]
assert output["error"] == "disk full"
assert output["pending"]["notification_id"] == 42
def test_follow_up_staging_never_silently_overwrites_an_existing_capture():
script = f"""
const createCapture = require({json.dumps(str(CREATE_ISSUE_SHEET))});
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 capture = createCapture({{fetchJson:async()=>[], storage}});
capture.saveDraft({{repository:'o/existing', title:'Existing draft', body:'Keep me'}});
const state = capture.stageFollowUp({{repository:'o/new', title:'Follow up', body:'Source: https://example.test/1'}});
const before = capture.loadDraft();
const accepted = capture.acceptFollowUp();
process.stdout.write(JSON.stringify({{state, before, accepted, pending:capture.pendingFollowUp()}}));
"""
assert run_node(script) == {
"state": {"status": "conflict"},
"before": {
"repository": "o/existing",
"title": "Existing draft",
"body": "Keep me",
"labelIds": [],
},
"accepted": {
"repository": "o/new",
"title": "Follow up",
"body": "Source: https://example.test/1",
"labelIds": [],
},
"pending": None,
}
@pytest.mark.anyio
async def test_update_sheet_wires_phone_safe_follow_up_without_marking_read():
html = await dashboard()
assert 'name="stackchain-feature-issue-capture"' in html
assert 'id="create-update-follow-up"' in html
assert '>Create follow-up</button>' in html
assert "issueCapture.stageFollowUp(updateFollowUp.draft(selectedUpdateDetail))" in html
assert "qs('#create-update-follow-up').addEventListener('click'" in html
assert "markNotificationRead" not in html.split("qs('#create-update-follow-up').addEventListener('click'", 1)[1].split("});", 1)[0]
assert '.update-sheet-actions button, .update-sheet-actions a { min-height:44px;' in html
@pytest.mark.anyio
async def test_follow_up_capture_offers_durable_create_and_next_flow():
html = await dashboard()
assert 'id="create-follow-up-next"' in html
assert '>Create follow-up &amp; next</button>' in html
assert "updateFollowUp.stageSource(source.item)" in html
assert "event.submitter?.id === 'create-follow-up-next'" in html
assert "queueRead: notificationId => notificationReadOutbox.enqueueDurably(notificationId)" in html
assert "advance: source => notificationReader.acceptReadAndNext(lastMyWork, source)" in html
assert ".create-issue-actions button { min-height:44px;" in html