Compare commits

...
Author SHA1 Message Date
timmy e5c5c8ec31 feat: continue mobile work across queues (Closes #683)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:57:34 +00:00
timmy 87e0d74414 Merge pull request 'Filter Find Work by repository and label' (#682) from timmy/681-find-work-facets into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 21:35:47 +00:00
timmy f031a37e8d feat: filter Find Work by repository and label (Closes #681)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:33:27 +00:00
timmy 057bec7dc2 Merge pull request 'Resume interrupted Find Work batches without duplicate claims' (#680) from timmy/679-resume-find-work-batches into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-12 21:04:39 +00:00
timmy 654ae8714e feat: resume interrupted Find Work batches (Closes #679)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 21:02:25 +00:00
timmy a0dd044995 Merge pull request 'Fill remaining Today slots from Find Work' (#678) from timmy/677-fill-today-from-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m27s
2026-08-12 20:27:50 +00:00
timmy c93a682dfb feat: fill Today from ranked Find Work results (Closes #677)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 20:25:37 +00:00
timmy 5675ca5d41 Merge pull request 'Make Find Work batch claiming respect Today time capacity' (#676) from timmy/675-find-work-time-capacity into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 19:56:09 +00:00
timmy 0767ccaa9d feat: budget Find Work batches by time (Closes #675)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:53:57 +00:00
timmy c6eabb5fc7 Merge pull request 'Search the full Find Work catalog' (#674) from timmy/673-search-find-work into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 19:27:10 +00:00
timmy 77fab85a66 feat: search the full Find Work catalog (Closes #673)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 19:24:55 +00:00
timmy dcf26c896c Merge pull request 'Batch-claim available work into Today from Find Work' (#672) from timmy/671-batch-claim-find-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-12 19:02:54 +00:00
timmy fba1f4238d feat: batch-claim available work into Today (Closes #671)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:58:26 +00:00
timmy 9e301924b4 Merge pull request 'Select all matching work for mobile batch planning' (#670) from timmy/669-select-all-matching-work into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-12 18:25:41 +00:00
timmy 8edd542379 feat: select matching mobile work in one step (Closes #669)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 18:23:34 +00:00
timmy 0fbe047e03 Merge pull request 'Find work within the active mobile queue' (#668) from timmy/667-find-active-queue into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-12 17:58:48 +00:00
timmy 995d8f8527 feat: find work within active queues (Closes #667)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
Closes #665
2026-08-12 17:13:59 +00:00
timmy 2553e7121b feat: create follow-up and continue updates (Closes #665)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m25s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 16:35:34 +00:00
timmy d96a77c5d5 feat: create follow-up work from updates (Closes #663)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m32s
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 / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
2026-08-12 16:05:43 +00:00
timmy 80a4096118 feat: batch-plan mobile work queues (Closes #661)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m42s
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 / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
2026-08-12 15:14:22 +00:00
timmy ea3712ca3e feat: defer selected updates as unread batch (Closes #659)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
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 / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m56s
2026-08-12 14:31:25 +00:00
timmy 982c2a1fb7 feat: make mobile work queues addressable (Closes #657)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m38s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m22s
2026-08-12 13:55:56 +00:00
timmy 32394fcd8f fix: isolate Web Push device failures (Closes #655)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
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 / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m46s
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 / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m54s
2026-08-12 13:04:37 +00:00
timmy c4ba78c8cb feat: consolidate mobile work commands (Closes #651)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m51s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m33s
2026-08-12 12:37:12 +00:00
timmy b81d695585 feat: undo accidental update acknowledgements (Closes #649)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m43s
2026-08-12 12:04:40 +00:00
timmy f78d69fba5 fix: open push digests in Updates inbox (Closes #647)
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 1m38s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m39s
2026-08-12 11:28:51 +00:00
timmy 48f75cff05 feat: launch work directly from mobile queues (Closes #645)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m36s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m30s
2026-08-12 10:56:08 +00:00
timmy 54ff44462e fix: keep mobile inline review above keyboard (Closes #643)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m36s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 10:29:03 +00:00
timmy c91194c283 fix: detect atomic passkey counter anomalies (Closes #641)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-12 09:54:56 +00:00
timmy 95f24a4e90 feat: expose Updates in mobile queues (Closes #639)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m40s
Closes #637
2026-08-12 09:28:47 +00:00
timmy bbc3e10525 perf: bound Build my Today readiness checks (Closes #637)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m29s
2026-08-12 08:58:11 +00:00
timmy a08a51af02 feat: finish Today plans with inline estimates (Closes #635)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-12 08:38:49 +00:00
timmy a91788276c feat: build a ready capacity-fit Today plan (Closes #633)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m30s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-12 07:57:53 +00:00
timmy b8befc2041 fix: make Draft admission retry-safe (Closes #631)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m29s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-12 07:20:10 +00:00
timmy 7948d2e353 feat: file mobile Drafts sequentially (Closes #629)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m29s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m41s
2026-08-12 06:29:46 +00:00
timmy 8d142c808b feat: unify mobile work queues (Closes #627)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m34s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m22s
2026-08-12 05:56:46 +00:00
timmy 285fdff869 fix: preserve Drafts when cleanup fails (Closes #625)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m32s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m34s
2026-08-12 05:38:48 +00:00
timmy fcb0575e38 feat: guard mobile Draft capacity (Closes #623)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m31s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-12 04:57:56 +00:00
timmy a68639668c feat: complete mobile Draft handoff (Closes #621)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 04:02:55 +00:00
timmy ae07dd185c fix: purge capture Draft screenshots (Closes #617)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m31s
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 / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-12 03:35:30 +00:00
timmy 48e4344f28 feat: preserve capture-first screenshots in Drafts (Closes #615)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
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 / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
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 / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m27s
2026-08-12 02:28:30 +00:00
timmy 9edd3f6441 feat: start empty Today planning from mobile dock (Closes #611)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 02:24:10 +00:00
rockachopa 4faf0447be Merge pull request 'Bind operator mode to a canonical public origin' (#610) from timmy/609-canonical-public-origin into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m27s
2026-08-12 01:57:06 +00:00
timmy 8ea46974ff feat: bind operator mode to public origin (Closes #609)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:54:51 +00:00
rockachopa 075a277265 Merge pull request 'Log Today recap time concurrently within the foreground deadline' (#608)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-12 01:24:43 +00:00
timmy a7ec4437d9 feat: bound concurrent recap time logging (Closes #607)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m30s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:22:33 +00:00
timmy bf2cf37151 Merge pull request 'Lazy-load active devices and security activity' (#606) from timmy/605-lazy-security-center into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m30s
2026-08-12 01:03:13 +00:00
timmy fb58ba30b2 feat: lazy-load security center (Closes #605)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 01:00:32 +00:00
rockachopa 17823352f2 Merge pull request 'Review unfinished Today work when a new local day begins' (#604) from timmy/603-today-day-rollover into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
2026-08-12 00:40:49 +00:00
timmy 14a3e1ee8d feat: review Today work at day rollover (Closes #603)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m33s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 00:38:13 +00:00
timmy 8dc155e49a Merge pull request 'Complete Today from the mobile session HUD' (#602) from timmy/601-mobile-hud-complete into main
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m31s
2026-08-11 23:58:47 +00:00
timmy 7aa25f1d8c feat: complete Today from mobile HUD (Closes #601)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:56:39 +00:00
timmy f023414929 Merge pull request 'Keep the active Today session visible on mobile' (#600) from timmy/599-mobile-today-session-hud into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-11 23:36:22 +00:00
timmy 96e7b35419 feat: keep active Today visible on mobile (Closes #599)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:29:50 +00:00
rockachopa 4c13e13275 Merge pull request 'Replan Today from live budget risk' (#598) from timmy/597-live-today-budget into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-11 23:07:01 +00:00
timmy ebe19f2281 feat: replan Today from live budget risk (Closes #597)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 23:04:49 +00:00
timmy 798790ce1a Merge pull request 'Pause Today while handling Attention' (#596)
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m37s
Closes #595
2026-08-11 22:32:20 +00:00
timmy 9629643a63 feat: pause Today while handling Attention (Closes #595)
CI / build-release (pull_request) Successful in 10s
CI / lint (pull_request) Successful in 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 22:27:53 +00:00
timmy 9f3855d779 Keep mobile Attention actionable alongside Today
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Keep mobile Attention actionable alongside Today (#594)
2026-08-11 21:41:26 +00:00
timmy c99dd3e3be test: make async deadline checks contention-safe
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m39s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:39:04 +00:00
timmy 7e173640ed refactor: keep mobile work mode update-local
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m31s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:34:22 +00:00
timmy 87f63370f6 feat: keep mobile Attention actionable alongside Today (Closes #593)
CI / build-release (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 21:25:27 +00:00
rockachopa 614ffae188 Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592) from timmy/591-reconcile-today-interruptions into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Merge pull request 'Reconcile long mobile interruptions before resuming Today time' (#592)
2026-08-11 21:07:41 +00:00
timmy aaa9aef78b feat: reconcile long Today interruptions (Closes #591)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m26s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:56:40 +00:00
rockachopa 10a8b2738b Merge pull request 'Require fresh authorization and audit recap time logging' (#590) from timmy/589-authorize-audit-recap-time into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m38s
2026-08-11 20:29:36 +00:00
timmy 379f2b8203 feat: authorize and audit recap time logging (Closes #589)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m29s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 20:26:10 +00:00
rockachopa 53bf89bd47 Merge pull request 'Log corrected Today recap time to Gitea' (#588)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m26s
Closes #587
2026-08-11 19:43:01 +00:00
timmy 697f72da09 feat: log corrected Today recap time (Closes #587)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m25s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 19:40:45 +00:00
timmy c170240057 Merge pull request 'Resume recap-to-plan feedback after interruptions' (#586) from timmy/585-resume-recap-replan into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-11 18:59:24 +00:00
timmy 3043ccc846 feat: resume recap feedback after interruptions (Closes #585)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 18:57:11 +00:00
rockachopa c5ddb1275b Merge pull request 'Turn Today recap feedback into an adjusted plan' (#584) from timmy/583-recap-replan into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m23s
2026-08-11 18:36:20 +00:00
timmy 1fb26e0a86 feat: turn Today recap feedback into a plan
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m24s
CI / release-candidate (pull_request) Has been skipped
Closes #583
2026-08-11 18:33:59 +00:00
rockachopa be7bfcaa5a Merge pull request 'Preserve Today recap drafts across offline reloads' (#582)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m40s
Closes #581
2026-08-11 18:01:33 +00:00
timmy 6a3c6c7a9e feat: preserve offline Today recap drafts (Closes #581)
CI / build-release (pull_request) Successful in 7s
CI / lint (pull_request) Successful in 1m52s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:58:50 +00:00
rockachopa cab230e3a2 Merge pull request 'Add synced Today recap with estimate-versus-actual history' (#580) from timmy/579-today-recap into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m22s
2026-08-11 17:24:11 +00:00
timmy 80ea81ad73 feat: add synced Today recaps (Closes #579)
CI / build-release (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 1m34s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 17:21:22 +00:00
timmy 8cd2459468 Merge pull request 'Track interruption-safe actual time through Today' (#578) from timmy/577-today-actual-time into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m28s
2026-08-11 16:17:03 +00:00
timmy 7ee10619a4 feat: track actual time through Today (Closes #577)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m27s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 16:14:17 +00:00
timmy a8da5ce949 Merge pull request 'Edit or delete your own conversation comments' (#576) from timmy/575-owned-comment-actions into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m28s
Merge pull request 'Edit or delete your own conversation comments' (#576)
2026-08-11 15:23:16 +00:00
timmy 8e357a097b feat: edit and delete owned comments (Closes #575)
CI / build-release (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 1m28s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 15:18:30 +00:00
timmy 43f5833f7f Merge pull request 'Make Web Push pagination-complete and reconcile unread delivery state' (#574) from timmy/573-complete-push-snapshot into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-11 14:34:39 +00:00
timmy 27fff15da4 feat: complete unread push snapshots (Closes #573)
CI / build-release (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 1m23s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 14:32:19 +00:00
timmy a2b78710f2 Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572) from timmy/571-revision-aware-web-push into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m22s
Merge pull request 'Re-alert Web Push when unread threads receive new updates' (#572)
2026-08-11 13:54:24 +00:00
timmy e9271f5aab feat: re-alert updated Web Push threads (Closes #571)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m22s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 13:51:58 +00:00
timmy 257bc0110e Merge pull request 'Send bounded Web Push digests for update bursts' (#570) from timmy/569-push-burst-digests into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m33s
2026-08-11 12:59:31 +00:00
timmy 5e0683cd4a feat: send bounded push digests for update bursts (Closes #569)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m19s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 12:57:07 +00:00
timmy c94fc5308f Merge pull request 'Hand off assigned pull requests and continue Today' (#568) from timmy/567-pull-ownership into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m21s
feat: hand off assigned pull requests (Closes #567)
2026-08-11 12:40:16 +00:00
timmy 0304f5ab2c feat: hand off assigned pull requests (Closes #567)
CI / lint (pull_request) Successful in 1m19s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 11:49:15 +00:00
timmy 50d51ed159 Merge pull request 'Make unread Web Push use the production notification contract' (#566) from timmy/565-production-push-contract into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m11s
2026-08-11 10:57:48 +00:00
timmy 8e74f022eb fix: use production IDs for unread Web Push (Closes #565)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:55:38 +00:00
timmy 92b0fd56ca Merge pull request 'Defer unread updates until tomorrow from Web Push' (#564) from timmy/563-web-push-tomorrow into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m13s
Merge pull request #564 from timmy/563-web-push-tomorrow
2026-08-11 10:27:57 +00:00
timmy 7ce352553d feat: defer Web Push updates until tomorrow (Closes #563)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 10:25:56 +00:00
timmy f4a4ed84c3 Merge pull request 'Mark unread updates read from Web Push' (#562) from timmy/561-web-push-mark-read into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m16s
2026-08-11 09:58:56 +00:00
timmy b21888fd7a feat: mark Web Push updates read (Closes #561)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m12s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:57:05 +00:00
timmy 320d6348ca Merge pull request 'Surface mobile device readiness before My Work' (#560) from timmy/559-mobile-readiness-card into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m14s
2026-08-11 09:35:52 +00:00
timmy 7f8ee771dc feat: surface mobile device readiness (Closes #559)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m16s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 09:33:27 +00:00
timmy f446e10670 Merge pull request 'Bound Web Push fan-out latency across devices' (#558) from timmy/557-bounded-push-fanout into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m15s
2026-08-11 08:56:57 +00:00
timmy 79309196b8 perf: bound Web Push device fan-out (Closes #557)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m14s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:54:48 +00:00
timmy cdf6ecdcb8 Merge pull request 'Guide mobile operators through device readiness setup' (#556)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m14s
Closes #555
2026-08-11 08:35:08 +00:00
timmy 797fd0d544 feat: guide mobile device readiness (Closes #555)
CI / build-release (pull_request) Successful in 5s
CI / lint (pull_request) Successful in 1m13s
CI / release-candidate (pull_request) Has been skipped
2026-08-11 08:33:01 +00:00
timmy a13e25f6c9 Merge pull request 'Stop Web Push when device sessions expire' (#554)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m9s
Closes #553
2026-08-11 07:57:06 +00:00
89 changed files with 11770 additions and 669 deletions

No files matched your search

+35 -13
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.
@@ -67,7 +70,8 @@ and an active Today session shows the current estimate plus estimated remaining
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
as unknown rather than unblocked. Starting a Today work session also
stores an account-bound checkpoint on the current device. After a reload or installed-app
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
After a reload or installed-app
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
**Comment & next** on that current issue or pull request posts the handoff online or admits it
to durable account-bound delivery, then removes the item only from Today and opens the next
@@ -162,11 +166,12 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
# Recommended behind a proxy; WebAuthn assertions must match these public values.
# Optional compatibility overrides; defaults derive from the required public origin.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours.
@@ -188,15 +193,27 @@ export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts
# Trust forwarding headers only from these immediate reverse-proxy networks.
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
# The feature stays disabled unless all three values are present.
# The feature stays disabled unless all three values are present. Privacy-safe update
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
# 09:00 in the device's local timezone without opening the dashboard. Bursts send
# 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'
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
# 60-second renewable cross-worker lease, and
# STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3.
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
# within each device; one slow device does not delay healthy devices behind it.
export STACKCHAIN_PUSH_POLL_SECONDS=30
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
# Maximum individual alerts per device and poll before one digest covers the rest.
export STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS=3
export STACKCHAIN_PUSH_LEASE_SECONDS=60
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
uvicorn src.main:app --host 127.0.0.1 --port 8000
@@ -257,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
+2 -1
View File
@@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
"""Keep tests intentional now that deployed authentication fails closed."""
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
+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;
}
+153
View File
@@ -0,0 +1,153 @@
function createBatchFindWork({
capacity,
claim,
queue,
timeBudget = () => ({ capacity_minutes: null, planned_minutes: 0 }),
persistEstimate = () => {},
onProgress = () => {},
storage = typeof localStorage === 'undefined' ? null : localStorage,
owner = () => '',
}) {
let request = null;
const journalKey = () => 'stackchain.find-work-batch.v1.' + encodeURIComponent(String(owner() || ''));
function readJournal() {
if (!storage || !String(owner() || '')) return null;
try {
const value = JSON.parse(storage.getItem(journalKey()) || 'null');
return value?.owner === String(owner()) && Array.isArray(value.items) ? value : null;
} catch (_error) { return null; }
}
function writeJournal(value) {
if (!storage || !String(owner() || '')) return;
storage.setItem(journalKey(), JSON.stringify(value));
}
function clearJournal() {
if (storage && String(owner() || '')) storage.removeItem(journalKey());
}
function result(status, selected, available, queued = [], failed = []) {
return { status, selected, available, queued, failed };
}
function key(item) {
return String(item.repository || '') + '#' + String(item.number || '');
}
async function processJournal(journal) {
const selected = journal.items;
const queued = [];
const failed = [];
for (let index = 0; index < selected.length; index += 1) {
const entry = selected[index];
const item = entry.item;
if (entry.state === 'queued') {
queued.push(key(item));
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
continue;
}
try {
const confirmed = entry.confirmed || await claim(item);
if (!entry.confirmed) {
entry.confirmed = confirmed;
entry.state = 'assigned';
writeJournal(journal);
}
const queueResult = await queue(confirmed);
if (queueResult === 'queued' || queueResult === 'exists') {
queued.push(key(item));
entry.state = 'queued';
if (Number.isInteger(entry.estimate) && entry.estimate > 0) {
persistEstimate(confirmed, entry.estimate);
}
writeJournal(journal);
} else {
failed.push({ key: key(item), reason: 'assigned but Today sync is unavailable', assigned: true });
}
} catch (error) {
failed.push({ key: key(item), reason: error?.message || 'assignment failed' });
}
onProgress({ status: 'running', processed: index + 1, selected: selected.length });
}
const outcome = result('complete', selected.length, journal.available, queued, failed);
if (!failed.length) clearJournal();
else writeJournal(journal);
onProgress({ ...outcome, processed: selected.length });
return outcome;
}
function run(items, estimates = {}) {
if (request) return request;
const selected = Array.isArray(items) ? items.slice() : [];
const available = Math.max(0, Number(capacity()) || 0);
if (selected.length > available) {
const outcome = result('full', selected.length, available);
onProgress(outcome);
return Promise.resolve(outcome);
}
const budget = timeBudget() || {};
if (Number.isInteger(budget.capacity_minutes) && budget.capacity_minutes > 0) {
const remaining = Math.max(0, budget.capacity_minutes - (Number(budget.planned_minutes) || 0));
const invalid = selected.map(key).filter(id =>
!Number.isInteger(estimates[id]) || estimates[id] <= 0
);
const requested = selected.reduce((sum, item) => sum +
(Number.isInteger(estimates[key(item)]) && estimates[key(item)] > 0 ? estimates[key(item)] : 0), 0);
if (invalid.length) {
const outcome = { ...result('estimates-required', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, invalid };
onProgress(outcome);
return Promise.resolve(outcome);
}
if (requested > remaining) {
const outcome = { ...result('over-budget', selected.length, available),
remaining_minutes: remaining, requested_minutes: requested, over_minutes: requested - remaining };
onProgress(outcome);
return Promise.resolve(outcome);
}
}
const journal = {
owner: String(owner() || ''), available,
items: selected.map(item => ({ item, estimate: estimates[key(item)] || null, state: 'pending' })),
};
writeJournal(journal);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function resume() {
if (request) return request;
const journal = readJournal();
if (!journal) return Promise.resolve(null);
request = processJournal(journal).finally(() => { request = null; });
return request;
}
function mountRecovery(button, opener) {
const show = () => {
const journal = readJournal();
button.hidden = !journal;
if (journal) button.textContent = 'Resume ' + journal.items.filter(item => item.state !== 'queued').length + ' interrupted';
};
button.addEventListener('click', async () => {
button.disabled = true;
const outcome = await resume();
button.disabled = false;
show();
if (outcome) {
button.previousElementSibling.textContent = outcome.failed.length ?
outcome.failed.length + ' still need recovery.' : outcome.queued.length + ' queued · batch recovered.';
}
});
opener.addEventListener('click', show);
}
if (typeof document !== 'undefined') mountRecovery(
document.getElementById('resume-find-work-batch'), document.getElementById('find-work')
);
return { run, resume, mountRecovery };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createBatchFindWork;
+103
View File
@@ -0,0 +1,103 @@
function createCommentActions({ fetchJson, getLogin, confirmDelete = () => false }) {
const encodedRepository = repository => String(repository || '').split('/')
.map(encodeURIComponent).join('/');
function pathFor(context, commentId) {
const item = context?.item || {};
if (context?.kind === 'update') {
return 'api/v1/notifications/' + encodeURIComponent(item.notification_id) +
'/comments/' + encodeURIComponent(commentId);
}
if (!['issue', 'pull'].includes(context?.kind)) throw new Error('Comment conversation is unavailable.');
return 'api/v1/repos/' + encodedRepository(item.repository) + '/' +
(context.kind === 'pull' ? 'pulls/' : 'issues/') + encodeURIComponent(item.number) +
'/comments/' + encodeURIComponent(commentId);
}
const controller = {
isOwned(comment) {
const login = String(getLogin() || '').trim();
return Boolean(login && comment && comment.author === login);
},
async edit(context, pager, commentId, body) {
const draft = String(body || '').trim();
if (!draft) throw new Error('Comment must not be blank.');
const comment = await fetchJson(pathFor(context, commentId), {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ body: draft }),
});
pager.replace(comment);
return pager.snapshot();
},
async remove(context, pager, commentId) {
if (!confirmDelete('Delete this comment permanently?')) return null;
const result = await fetchJson(pathFor(context, commentId), {
method: 'DELETE', headers: { Accept: 'application/json' },
});
if (!result?.deleted || Number(result.id) !== Number(commentId)) {
throw new Error('Comment deletion was not confirmed.');
}
pager.remove(commentId);
return pager.snapshot();
},
actionHtml(comment) {
return controller.isOwned(comment) ?
'<div class="comment-owned-actions" aria-label="Your comment actions">' +
'<button type="button" data-comment-action="edit">Edit</button>' +
'<button type="button" data-comment-action="delete">Delete</button></div>' : '';
},
wire({ root, getSurface, isOffline, escapeHtml }) {
root.addEventListener('click', async event => {
const button = event.target.closest('[data-comment-action]');
if (!button) return;
const card = button.closest('.issue-comment');
const commentId = Number(card?.dataset.commentId);
const surface = getSurface();
const comment = surface.pager?.snapshot().comments.find(item => item.id === commentId);
if (!comment || !controller.isOwned(comment)) return;
if (isOffline()) {
surface.status.textContent = 'Reconnect to edit or delete this comment.';
return;
}
if (button.dataset.commentAction === 'delete') {
try {
const state = await controller.remove(surface.context, surface.pager, commentId);
if (state) {
surface.render(state);
surface.status.textContent = 'Comment deleted.';
}
} catch (error) {
surface.status.textContent = error.message || 'Comment deletion failed. Retry or open it in Gitea.';
}
return;
}
card.innerHTML = '<div class="small">Editing your comment</div>' +
'<textarea class="comment-edit-textarea" maxlength="10000" aria-label="Edit comment">' +
escapeHtml(comment.body || '') + '</textarea>' +
'<div class="comment-owned-actions"><button type="button" data-comment-edit-save>Save</button>' +
'<button type="button" data-comment-edit-cancel>Cancel</button></div>';
const textarea = card.querySelector('.comment-edit-textarea');
textarea.focus();
card.querySelector('[data-comment-edit-cancel]').addEventListener('click', () => surface.render(surface.pager.snapshot()));
card.querySelector('[data-comment-edit-save]').addEventListener('click', async saveEvent => {
const save = saveEvent.currentTarget;
save.disabled = true;
surface.status.textContent = 'Saving comment…';
try {
const state = await controller.edit(surface.context, surface.pager, commentId, textarea.value);
surface.render(state);
surface.status.textContent = 'Comment updated.';
} catch (error) {
save.disabled = false;
surface.status.textContent = error.message || 'Comment update failed. Your edit is safe; retry or open it in Gitea.';
textarea.focus();
}
});
});
},
};
return controller;
}
if (typeof module !== 'undefined' && module.exports) module.exports = createCommentActions;
+15
View File
@@ -63,6 +63,21 @@ function createConversationPager({ loadPage }) {
}
return snapshot();
},
replace(comment) {
if (!comment || !Number.isInteger(comment.id)) return snapshot();
state = {
...state,
comments: state.comments.map(existing => existing.id === comment.id ? { ...comment } : existing),
};
return snapshot();
},
remove(commentId) {
const comments = state.comments.filter(comment => comment.id !== commentId);
if (comments.length !== state.comments.length) {
state = { ...state, comments, total: Math.max(0, state.total - 1) };
}
return snapshot();
},
};
}
+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,
};
}
+171 -11
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; }
@@ -18,6 +29,21 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-devices-header h2, .active-devices-header p { margin-top:0; }
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
.active-devices-list { display:grid; gap:10px; margin-top:16px; }
.device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.device-setup-sheet[hidden] { display:none; }
.device-setup-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 #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.device-setup-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.device-setup-header h2, .device-setup-header p { margin-top:0; }
.device-setup-header button, .device-setup-action { min-height:44px; }
.device-setup-list { display:grid; gap:10px; margin:14px 0; padding:0; list-style:none; }
.device-setup-step { min-width:0; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#10233a; overflow-wrap:anywhere; }
.device-setup-step p { margin:5px 0 0; }
.device-setup-ready { margin:0; padding:12px; border-radius:10px; background:#0f2237; color:#bfdbfe; font-weight:700; }
.device-readiness-card { display:none; min-width:0; margin:10px 0; padding:12px; border:1px solid #3b82b8; border-radius:12px; background:#102b46; overflow-x:hidden; }
.device-readiness-card p { margin:5px 0 0; overflow-wrap:anywhere; }
.device-readiness-summary { color:#bfdbfe; font-weight:700; }
.device-readiness-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.device-readiness-actions button { min-height:44px; min-width:0; }
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
@@ -35,6 +61,10 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
#load-more-security-activity { width:100%; min-height:44px; }
button:hover { filter: brightness(1.15); }
.issue-comment { min-width:0; overflow-wrap:anywhere; }
.comment-owned-actions { display:flex; gap:8px; flex-wrap:wrap; margin:8px 0; }
.comment-owned-actions button { min-height:44px; min-width:72px; }
.comment-edit-textarea { box-sizing:border-box; display:block; width:100%; max-width:100%; min-height:132px; resize:vertical; overflow-wrap:anywhere; }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
.panel > summary h2 { display: inline-block; margin-left: 4px; }
@@ -101,13 +131,24 @@ textarea { resize: vertical; min-height: 120px; }
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.plan-today-header h2, .plan-today-header p { margin-top:0; }
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-header button { min-width:44px; min-height:44px; }
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
.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; }
.plan-today-list, .plan-today-candidates { display:grid; gap:8px; }
.plan-today-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:10px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; }
.plan-today-item-copy { min-width:0; overflow-wrap:anywhere; }
@@ -118,6 +159,38 @@ textarea { resize: vertical; min-height: 120px; }
.plan-preview-actions button { min-height:44px; }
.plan-today-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.plan-today-actions button { min-height:44px; width:100%; }
.today-interruption-sheet { position:fixed; inset:0; z-index:92; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-interruption-sheet[hidden] { display:none; }
.today-interruption-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; }
.today-interruption-panel h2 { margin:.25rem 0; overflow-wrap:anywhere; }
.today-interruption-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; }
.today-interruption-actions button { min-height:44px; width:100%; }
.today-recap-sheet { position:fixed; inset:0; z-index:88; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-recap-sheet[hidden] { display:none; }
.today-recap-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-recap-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-recap-header h2 { margin:.2rem 0; }
.today-recap-header button { min-height:44px; min-width:44px; }
.today-recap-items, .today-recap-history { display:grid; gap:8px; }
.today-recap-row, .today-recap-history-row { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:10px; }
.today-recap-row strong { overflow-wrap:anywhere; }
.today-recap-row input { width:6rem; min-height:44px; }
.today-recap-context { display:block; overflow-wrap:anywhere; }
.today-recap-variance { grid-column:1 / -1; color:#bfdbfe; font-weight:700; }
.today-recap-log { grid-column:1 / -1; display:flex; align-items:center; gap:8px; min-height:44px; }
.today-recap-log input { width:24px; min-height:24px; }
.today-recap-log-success { color:#86efac; font-weight:700; }
.today-recap-log-verify { color:#fde68a; font-weight:700; }
.today-recap-totals { margin:12px 0; padding:12px; border-radius:10px; background:#10233d; font-weight:700; }
.today-recap-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-recap-actions button { min-height:44px; width:100%; }
@media (max-width:420px) {
.today-interruption-actions { grid-template-columns:1fr; }
.today-recap-actions { grid-template-columns:1fr; }
.today-recap-row { grid-template-columns:1fr; }
.today-recap-row input { width:min(100%,8rem); }
.today-recap-variance { grid-column:1; }
}
.today-readiness-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-readiness-sheet[hidden] { display:none; }
.today-readiness-panel { box-sizing:border-box; width:min(620px,100%); max-height:100%; 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; }
@@ -135,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%; }
@@ -149,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; }
@@ -156,8 +241,12 @@ 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; }
.selection-scope-actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; max-width:100%; }
.selection-scope-actions button { min-height:44px; max-width:100%; }
.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; }
@@ -190,6 +279,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; }
@@ -341,27 +433,58 @@ textarea { resize: vertical; min-height: 120px; }
.issue-sheet-actions { position:sticky; bottom:0; z-index:3; display:grid; gap:8px; margin-top:14px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.issue-sheet-actions button, .issue-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.issue-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.issue-handoff { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff, .pull-ownership { margin-top:14px; padding:12px; border:1px solid #2a496e; border-radius:12px; }
.issue-handoff > div, .pull-ownership > div { display:grid; gap:8px; margin-top:10px; }
.issue-handoff select, .pull-ownership select { width:100%; max-width:100%; padding:8px; border:1px solid #1f3a5f; border-radius:8px; background:#0b1526; color:var(--text); }
.issue-handoff select, .issue-handoff button { min-height:44px; }
.pull-ownership select, .pull-ownership button { min-height:44px; }
.issue-retry { min-height:44px; width:100%; margin-top:10px; }
.new-issue { min-height:44px; }
.find-work-action { min-height:44px; }
.my-work-actions { display:flex; flex-wrap:wrap; gap:8px; }
.start-work-session { min-height:44px; }
.resume-today-session, .end-today-session { min-height:44px; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav { position:sticky; bottom:0; z-index:5; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:12px; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.work-session-nav[hidden] { display:none; }
.work-session-nav [data-work-session-progress] { grid-column:1 / -1; text-align:center; }
.work-session-nav [data-work-session-timer-toggle] { min-height:44px; }
.work-session-nav button { min-height:44px; width:100%; }
.find-work-sheet { position:fixed; inset:0; z-index:58; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
.find-work-sheet.open { display:flex; }
.find-work-panel { width:min(560px,100%); height:100dvh; overflow:auto; 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; }
.find-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.find-work-header-actions { display:flex; gap:8px; }
.find-work-header button, .find-work-card button, .find-work-card a, .find-work-more { min-height:44px; }
.find-work-search { position:sticky; top:0; z-index:2; display:grid; gap:6px; padding:8px 0; background:#0b1526; }
.find-work-search > div { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
.find-work-search input, .find-work-search button { min-height:44px; }
.find-work-filters { border:1px solid #2a496e; border-radius:10px; padding:10px; }
.find-work-filters summary { min-height:44px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; }
.find-work-filter-panel, .find-work-filter-options { display:grid; gap:8px; }
.find-work-filter-panel fieldset { min-width:0; border:0; padding:6px 0; margin:0; }
.find-work-filter-options { grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
.find-work-filter-option { min-height:44px; display:flex; align-items:center; gap:8px; padding:6px; overflow-wrap:anywhere; }
.find-work-filter-option input { width:20px; height:20px; flex:0 0 auto; }
#clear-find-work-filters { min-height:44px; width:100%; }
.find-work-list { display:grid; gap:10px; }
.find-work-card { display:grid; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
.find-work-card.selected { border-color:#60a5fa; box-shadow:0 0 0 2px rgba(96,165,250,.25); }
.find-work-select { display:flex; align-items:center; gap:10px; min-height:44px; }
.find-work-select input { width:22px; height:22px; }
.fill-find-work-today { min-height:44px; width:100%; font-weight:700; }
.find-work-batch-actions { position:sticky; bottom:0; z-index:2; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:12px 0 calc(12px + env(safe-area-inset-bottom)); background:#0b1526; border-top:1px solid #2a496e; }
.find-work-batch-actions[hidden] { display:none; }
.find-work-batch-actions span { grid-column:1 / -1; }
.find-work-batch-actions button { min-height:44px; }
.find-work-batch-recovery { min-height:44px; width:100%; font-weight:700; }
.find-work-estimate-review { display:grid; gap:12px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
.find-work-estimate-review[hidden] { display:none; }
.find-work-estimate-review > div:first-child, #find-work-estimate-list { display:grid; gap:8px; }
.find-work-estimate-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(92px,auto); gap:10px; align-items:center; overflow-wrap:anywhere; }
.find-work-estimate-row label { display:flex; align-items:center; gap:6px; }
.find-work-estimate-review input { min-height:44px; width:76px; }
.find-work-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
.find-work-estimate-actions button { min-height:44px; }
.find-work-card button { width:100%; font-weight:700; }
.find-work-claim-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; }
.find-work-claim-actions [data-claim-start-index] { grid-column:1 / -1; }
@@ -394,8 +517,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; }
@@ -449,11 +576,18 @@ textarea { resize: vertical; min-height: 120px; }
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
.mobile-task-dock { display:none; }
.mobile-task-dock[hidden] { display:none; }
.mobile-today-hud { display:none; }
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
.mobile-task-action[hidden] { display:none; }
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
.app-brand .muted, #clock { display:none; }
.app-live-status { margin-left:auto; }
@@ -463,13 +597,18 @@ textarea { resize: vertical; min-height: 120px; }
.app-menu:not([open]) > .app-menu-panel { display:none; }
.app-menu-panel { position:absolute; top:calc(100% + 6px); right:0; width:min(280px,calc(100vw - 20px)); display:grid; gap:8px; padding:10px; border:1px solid #2a496e; border-radius:12px; background:#0b1526; box-shadow:0 18px 45px rgba(0,0,0,.5); }
.app-menu-panel button { min-height:44px; width:100%; }
.device-setup-panel { width:100%; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.device-setup-step { grid-template-columns:1fr; }
.device-setup-action { width:100%; }
.device-readiness-card:not([hidden]) { display:grid; gap:12px; margin-left:max(0px,env(safe-area-inset-left)); margin-right:max(0px,env(safe-area-inset-right)); }
.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; }
@@ -501,13 +640,34 @@ textarea { resize: vertical; min-height: 120px; }
.create-issue-panel.composer-keyboard-active,
.issue-sheet-panel.composer-keyboard-active,
.pull-sheet-panel.composer-keyboard-active,
.review-sheet-panel.composer-keyboard-active,
.update-sheet-panel.composer-keyboard-active { position:fixed; top:var(--composer-viewport-top,0px); right:0; height:var(--composer-viewport-height,100dvh); overscroll-behavior:contain; padding-bottom:calc(14px + env(safe-area-inset-bottom)); }
.composer-keyboard-active .create-issue-form :is(input,textarea,select,button) { scroll-margin-block:12px; }
.composer-keyboard-active .create-issue-form,
.composer-keyboard-active .issue-comment-composer,
.composer-keyboard-active .pull-comment-composer,
.composer-keyboard-active .review-inline-composer,
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
.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-queue-list [data-recommended="true"] { border-color:#60a5fa; box-shadow:0 0 0 2px #60a5fa; }
.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; }
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
.mobile-today-hud button { min-height:44px; max-width:100%; }
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
}
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
+854 -157
View File
File diff suppressed because it is too large. Load diff
+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;
+2 -1
View File
@@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
} catch (_error) {
if (status) {
const label = name === 'issue-capture' ? 'Issue capture' :
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
name === 'pull-workflow' ? 'Pull workspace' :
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
status.textContent = label + ' could not load. ' + retryLabel;
}
return false;
+247 -18
View File
@@ -18,6 +18,7 @@
<div class="app-menu-panel">
<button id="refresh">Refresh</button>
<button id="open-palette">Command</button>
<button id="open-device-setup" type="button">Device setup</button>
<button id="active-devices" type="button">Active devices</button>
<button id="sign-out" type="button">Sign out &amp; clear this device</button>
<button id="sign-out-all" type="button">Sign out all devices</button>
@@ -59,6 +60,33 @@
</section>
</div>
<div id="device-setup-sheet" class="device-setup-sheet" hidden>
<section class="device-setup-panel" role="dialog" aria-modal="true" aria-labelledby="device-setup-heading">
<div class="device-setup-header">
<div>
<h2 id="device-setup-heading">Make this device work-ready</h2>
<p class="small muted">Prepare this phone for fast launch, outages, and new update alerts. Nothing asks for permission until you choose it.</p>
</div>
<button id="close-device-setup" type="button" aria-label="Close device setup">Close</button>
</div>
<ol class="device-setup-list">
<li class="device-setup-step">
<div><strong>Install Stackchain</strong><p class="small" id="device-setup-install-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-install" type="button">Install</button>
</li>
<li class="device-setup-step">
<div><strong>Keep work available offline</strong><p class="small" id="device-setup-offline-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-offline" type="button">Enable</button>
</li>
<li class="device-setup-step">
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
</li>
</ol>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
</div>
<main>
<section class="panel my-work" id="my-work" tabindex="-1">
<div class="my-work-header">
@@ -68,6 +96,7 @@
</div>
<div class="my-work-actions">
<button class="plan-today" id="plan-today" type="button">Plan Today</button>
<button class="today-recap-history-action" id="open-today-recaps" type="button">Recaps</button>
<button class="start-work-session" id="start-work-session" type="button">Start work</button>
<button class="resume-today-session" id="resume-today-session" type="button" hidden>Resume Today</button>
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
@@ -118,11 +147,41 @@
</div>
</details>
</div>
<aside class="attention-interruption" id="attention-interruption" role="status" aria-live="polite" hidden>
<span><strong>Today paused</strong> while handling Attention</span>
<button id="return-to-today" type="button">Return to Today</button>
</aside>
<section class="device-readiness-card" id="device-readiness-card" aria-labelledby="device-readiness-heading" hidden>
<div>
<strong id="device-readiness-heading">Make this phone work-ready</strong>
<p class="small muted">Install, save work offline, and enable update alerts.</p>
<p class="small device-readiness-summary" id="device-readiness-summary" role="status" aria-live="polite"></p>
</div>
<div class="device-readiness-actions">
<button id="finish-device-setup" type="button">Finish setup</button>
<button id="dismiss-device-readiness" type="button">Not now</button>
</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>
<div class="selection-scope-actions" id="selection-scope-actions" hidden>
<button id="select-matching-work" type="button">Select all matches</button>
<button id="clear-work-selection" type="button">Clear selection</button>
</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>
@@ -134,7 +193,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">
@@ -210,7 +276,16 @@
<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">
<h3 id="today-plan-heading">Today, in order</h3>
<div class="plan-today-list" id="plan-today-list"></div>
@@ -226,6 +301,41 @@
</section>
</div>
<div class="today-interruption-sheet" id="today-interruption-sheet" role="dialog" aria-modal="true" aria-labelledby="today-interruption-title" hidden>
<section class="today-interruption-panel">
<div class="small">Today timer</div>
<h2 id="today-interruption-title">Were you working while away?</h2>
<p id="today-interruption-description" class="small"></p>
<p class="small muted">Choose once before the timer continues. You can include the full interval or remove it from your recap.</p>
<div class="today-interruption-actions">
<button type="button" data-today-interruption="count">Count this time</button>
<button type="button" data-today-interruption="exclude">Exclude this time</button>
</div>
</section>
</div>
<div class="today-recap-sheet" id="today-recap-sheet" role="dialog" aria-modal="true" aria-labelledby="today-recap-title" hidden>
<section class="today-recap-panel">
<div class="today-recap-header">
<div><div class="small">Estimate feedback</div><h2 id="today-recap-title">Today recap</h2></div>
<button id="close-today-recap" type="button">Close</button>
</div>
<p class="small muted">Review actual time before saving. Gitea time logging is explicit and unchecked by default.</p>
<div id="today-recap-status" class="small" role="status" aria-live="polite"></div>
<div id="today-recap-items" class="today-recap-items"></div>
<div id="today-recap-totals" class="today-recap-totals"></div>
<section aria-labelledby="today-recap-history-title">
<h3 id="today-recap-history-title">Recent recaps</h3>
<div id="today-recap-history" class="today-recap-history"></div>
</section>
<div class="today-recap-actions">
<button id="save-today-recap" type="button">Save recap &amp; adjust plan</button>
<button id="save-log-today-recap" type="button">Log selected time to Gitea</button>
<button id="discard-today-recap" type="button">Keep timer &amp; close</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel">
<div class="today-readiness-header">
@@ -411,6 +521,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="issue" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@@ -421,19 +532,51 @@
<section class="find-work-panel">
<div class="find-work-header">
<div><div class="small">Open · unassigned</div><h3 id="find-work-heading">Find work</h3></div>
<button id="close-find-work" type="button">Close</button>
<div class="find-work-header-actions"><button id="select-find-work" type="button">Select work</button><button id="close-find-work" type="button">Close</button></div>
</div>
<p class="small">Claim an available issue and continue it in My Work.</p>
<form class="find-work-search" id="find-work-search-form" role="search">
<label for="find-work-search">Search repository, #number, or title</label>
<div><input id="find-work-search" type="search" maxlength="100" autocomplete="off" />
<button id="clear-find-work-search" type="button" hidden>Clear</button></div>
<span id="find-work-match-status" class="small" aria-live="polite"></span>
</form>
<details class="find-work-filters" id="find-work-filters">
<summary>Filter work <span id="find-work-filter-count" class="small"></span></summary>
<div class="find-work-filter-panel" aria-label="Filter available work">
<fieldset><legend>Repositories</legend><div id="find-work-repository-filters" class="find-work-filter-options"></div></fieldset>
<fieldset><legend>Labels</legend><div id="find-work-label-filters" class="find-work-filter-options"></div></fieldset>
<button id="clear-find-work-filters" type="button">Clear filters</button>
</div>
</details>
<button class="fill-find-work-today" id="fill-find-work-today" type="button"
aria-describedby="find-work-selection-status">Fill remaining Today slots</button>
<div id="find-work-status" class="small" aria-live="assertive">Open Find Work to load available issues.</div>
<button class="find-work-batch-recovery" id="resume-find-work-batch" type="button"
aria-describedby="find-work-status" hidden>Resume assigning &amp; queueing</button>
<div class="find-work-list" id="find-work-list"></div>
<button class="find-work-more" id="load-more-available" type="button" hidden>Load more available issues</button>
<section class="find-work-estimate-review" id="find-work-estimate-review" aria-labelledby="find-work-estimate-heading" hidden>
<div><strong id="find-work-estimate-heading">Fit this work into Today</strong>
<span class="small" id="find-work-estimate-summary" aria-live="polite"></span></div>
<div id="find-work-estimate-list"></div>
<div class="find-work-estimate-actions">
<button id="cancel-find-work-estimates" type="button">Back</button>
<button id="confirm-find-work-estimates" type="button">Confirm &amp; assign</button>
</div>
</section>
<div class="find-work-batch-actions" id="batch-find-work-actions" hidden>
<span id="find-work-selection-status" class="small" aria-live="polite">No work selected.</span>
<button id="cancel-find-work-selection" type="button">Cancel</button>
<button id="claim-selected-work" type="button" disabled>Assign &amp; queue Today</button>
</div>
</section>
</div>
<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>
@@ -445,23 +588,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">
@@ -475,6 +611,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>
@@ -497,15 +653,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">
@@ -557,6 +729,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>
@@ -564,12 +737,18 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="update" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
</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">
@@ -616,14 +795,26 @@
<div id="pull-files"></div>
<button id="merge-pull" type="button" disabled>Merge</button>
</details>
<details class="pull-ownership" id="pull-ownership">
<summary>Ownership</summary>
<div>
<button id="load-pull-handoff" type="button">Choose teammate</button>
<label for="pull-handoff-recipient" class="small">Eligible repository assignee</label>
<select id="pull-handoff-recipient" disabled><option value="">Select a teammate</option></select>
<button id="confirm-pull-handoff" type="button" disabled>Confirm handoff</button>
<div id="pull-handoff-status" class="small" aria-live="assertive">Load teammates to transfer ownership.</div>
</div>
</details>
<div class="pull-sheet-actions">
<button class="share-work-route" type="button">Share</button>
<button id="release-pull" type="button">Release assignment</button>
<a id="open-pull-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>
</div>
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="pull" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@@ -698,6 +889,7 @@
<nav class="work-session-nav" aria-label="Work session" hidden>
<span class="small" aria-live="polite" data-work-session-progress></span>
<button type="button" data-work-session-previous>Previous</button>
<button type="button" data-work-session-timer-toggle>Pause timer</button>
<button type="button" data-work-session-complete="review" hidden>Done for Today &amp; next</button>
<button type="button" data-work-session-next>Next work item</button>
</nav>
@@ -716,24 +908,50 @@
</form>
</dialog>
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
<button data-mobile-today-complete type="button" hidden>Done &amp; next</button>
<div class="small" data-work-session-progress aria-live="polite"></div>
<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="find" type="button"><span><strong>Find Work</strong><small>Claim something new</small></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> <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page"><span id="mobile-work-label">Work</span></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>
<script src="static/session.js"></script>
<script src="static/feature-loader.js"></script>
<script src="static/security-center.js"></script>
<script src="static/markdown.js"></script>
<script src="static/commands.js"></script>
<script src="static/search-preview.js"></script>
<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>
@@ -744,23 +962,32 @@
<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>
<script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script>
<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>
<script src="static/detail-defer.js"></script>
<script src="static/later-picker.js"></script>
<script src="static/pick-work.js"></script>
<script src="static/batch-find-work.js"></script>
<script src="static/conversation.js"></script>
<script src="static/comment-actions.js"></script>
<script src="static/issue-attachment.js"></script>
<script src="static/issue-sheet.js"></script>
<script src="static/create-issue-sheet.js"></script>
@@ -774,8 +1001,10 @@
<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>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
+19 -2
View File
@@ -3,7 +3,8 @@
else root.createInstallApp = factory;
})(typeof self !== 'undefined' ? self : this, function createInstallApp(options) {
const dismissedKey = 'stackchain.install.dismissed.v1';
let deferredPrompt = null;
let deferredPrompt = options.initialPrompt || null;
let installed = false;
function hide() {
options.card.hidden = true;
@@ -25,6 +26,7 @@
await prompt.prompt();
const choice = await prompt.userChoice;
if (choice.outcome === 'accepted') {
installed = true;
hide();
options.status.textContent = 'Stackchain was added to your device.';
} else {
@@ -44,6 +46,7 @@
hide();
return;
}
if (deferredPrompt) showNativePrompt(deferredPrompt);
if (options.isIosSafari()) {
options.installButton.hidden = true;
options.guidance.hidden = false;
@@ -51,6 +54,7 @@
}
options.window.addEventListener('beforeinstallprompt', showNativePrompt);
options.window.addEventListener('appinstalled', () => {
installed = true;
deferredPrompt = null;
hide();
});
@@ -58,5 +62,18 @@
options.dismissButton.addEventListener('click', dismiss);
}
return {start};
function state() {
if (installed || options.isStandalone()) {
return {state:'complete', detail:'Stackchain is installed on this device.'};
}
if (options.isIosSafari()) {
return {state:'incomplete', detail:'On Safari, tap Share, then Add to Home Screen.'};
}
if (deferredPrompt) {
return {state:'incomplete', detail:'Stackchain is ready to install.'};
}
return {state:'unavailable', detail:'Installation is available from a supported browser.'};
}
return {start, install, state};
});
+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;
+126
View File
@@ -0,0 +1,126 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
];
let trigger = options.launcher;
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
return {available, complete};
}
function renderPrompt(readiness) {
if (!options.promptCard) return;
const {available, complete} = readinessCounts(readiness);
let dismissed = false;
try {
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
} catch (_error) {}
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
options.promptCard.hidden = dismissed || available === 0 || complete === available;
}
async function render() {
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
const step = readiness[name];
status.textContent = step.detail;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
if (step.state === 'complete') complete += 1;
});
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
renderPrompt(readiness);
return readiness;
}
async function open(event) {
trigger = event?.currentTarget || options.launcher;
await render();
options.sheet.hidden = false;
options.closeButton.focus();
}
function close() {
options.sheet.hidden = true;
trigger?.focus?.();
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
options.promptDismiss?.addEventListener('click', () => {
try {
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
} catch (_error) {}
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
button.disabled = true;
try { await action(); }
finally { await render(); }
});
});
renderPrompt(await options.getReadiness());
}
return {start, open, close, render};
});
if (typeof module === 'object' && module.exports) {
module.exports.mount = mountMobileDeviceSetup;
} else {
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
}
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
});
}
+49
View File
@@ -0,0 +1,49 @@
(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.',
};
const continuation = [
['attention', 'Start Attention'],
['today', 'Continue Today'],
['later', 'Start Later'],
['draft', 'Open Drafts'],
];
function recommend() {
const counts = options.getCounts ? options.getCounts() : {};
const match = continuation.find(([name]) => Number(counts[name]) > 0);
if (!match) return {name: 'find', count: 0, label: 'Find Work'};
const [name, label] = match;
const count = Math.max(0, Number(counts[name]) || 0);
return {name, count, label: label + ' (' + count + ')'};
}
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';
}
function continueWork() {
const next = recommend();
if (next.name === 'find') {
options.openFindWork();
return 'find';
}
return open(next.name);
}
return { open, recommend, continueWork };
});
+65 -17
View File
@@ -7,60 +7,108 @@
const overlays = options.overlays || [];
let launcher = null;
let wasHidden = false;
let workMode = 'work';
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');
else options.sessionHud.removeAttribute('data-overlay-hidden');
}
if (wasHidden && !hidden && launcher) launcher.focus();
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();
}
function updateWork(mode, count) {
function updateWork(mode) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
work: ['Work', 'Work'],
};
workMode = labels[mode] ? mode : 'work';
const total = Math.max(0, Number(count) || 0);
const badge = options.attentionBadge;
const workMode = labels[mode] ? mode : 'work';
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (badge) {
badge.textContent = String(total);
badge.hidden = total === 0;
}
if (buttons.work) {
buttons.work.setAttribute(
'aria-label',
labels[workMode][1] + (total ? ', ' + total + ' items need attention' : '')
);
buttons.work.setAttribute('aria-label', labels[workMode][1]);
}
}
function updateAttention(count) {
updateWork(workMode, count);
const total = Math.max(0, Number(count) || 0);
const visible = total > 0;
if (options.attentionBadge) {
options.attentionBadge.textContent = String(total);
options.attentionBadge.hidden = !visible;
}
if (buttons.attention) {
buttons.attention.hidden = !visible;
buttons.attention.setAttribute('aria-label', 'Attention, ' + total + ' items');
}
if (visible) nav.setAttribute('data-attention', 'true');
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;
}
+17 -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) {
@@ -395,13 +395,17 @@ function createNotificationReader({
return {
open,
commentPager() {
return conversationPager;
},
appendReply(comment) {
if (!conversationPager) return false;
onConversation(conversationPager.append(comment));
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() {
@@ -810,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,
@@ -833,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;
}
+115 -6
View File
@@ -1,14 +1,26 @@
function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
function createFindWork({ fetchJson, onItems, onPagination, onStatus, onSelection = () => {}, onFacets = () => {} }) {
let available = [];
let pagination = { page: 1, total: 0, has_more: false };
let loadRequest = null;
let loadGeneration = 0;
let query = '';
let filters = { repositories: [], labels: [] };
let facetView;
let claimRequest = null;
const previewed = new Set();
let selecting = false;
const selected = new Map();
function itemKey(item) {
return String(item?.repository || '') + '#' + String(item?.number || '');
}
function emitSelection() {
const state = { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
onSelection(state);
return state;
}
function apply(result, append) {
const incoming = Array.isArray(result?.items) ? result.items : [];
if (append) {
@@ -31,13 +43,31 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
onItems(available.slice());
onPagination({ ...pagination });
if (result?.facets) onFacets({
repositories: Array.isArray(result.facets.repositories) ? result.facets.repositories.slice() : [],
labels: Array.isArray(result.facets.labels) ? result.facets.labels.slice() : [],
});
if (result?.facets && typeof document !== 'undefined') {
facetView ||= attachFindWorkFacets(document.querySelector('#find-work-filters'), value =>
String(value).replace(/[&<>"']/g, character => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[character])),
next => { filters = next; loadPage(1, false); });
facetView(result.facets);
}
}
function loadPage(page, append) {
if (loadRequest) return loadRequest;
loadRequest = fetchJson('api/v1/available-issues?page=' + page, {
function loadPage(page, append, requestedQuery = query) {
if (loadRequest && requestedQuery === query && append) return loadRequest;
const generation = ++loadGeneration;
const params = new URLSearchParams();
params.set('facets', 'true');
if (requestedQuery) params.set('q', requestedQuery);
filters.repositories.forEach(value => params.append('repository', value));
filters.labels.forEach(value => params.append('label', value));
const queryPart = params.toString() ? '&' + params.toString() : '';
const request = fetchJson('api/v1/available-issues?page=' + page + queryPart, {
headers: { Accept: 'application/json' },
}).then(result => {
if (generation !== loadGeneration) return result;
apply(result, append);
if (result?.refresh_failed === true) {
onStatus('Showing saved available work. Catalog refresh failed; retrying shortly.');
@@ -45,8 +75,9 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
onStatus('Showing saved available work while the catalog refreshes…');
}
return result;
}).finally(() => { loadRequest = null; });
return loadRequest;
}).finally(() => { if (loadRequest === request) loadRequest = null; });
loadRequest = request;
return request;
}
return {
@@ -60,9 +91,66 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
if (!pagination.has_more) return Promise.resolve(false);
return loadPage(pagination.page + 1, true);
},
search(value) {
query = String(value || '').trim();
return loadPage(1, false, query);
},
query() {
return query;
},
setFilters(next) {
filters = {
repositories: Array.from(new Set((next?.repositories || []).map(String))).slice(0, 10),
labels: Array.from(new Set((next?.labels || []).map(String))).slice(0, 10),
};
return loadPage(1, false);
},
filters() {
return { repositories: filters.repositories.slice(), labels: filters.labels.slice() };
},
items() {
return available.slice();
},
startSelection() {
selecting = true;
selected.clear();
return emitSelection();
},
cancelSelection() {
selecting = false;
selected.clear();
return emitSelection();
},
toggleSelection(item) {
if (!selecting || !item) return emitSelection();
const key = itemKey(item);
if (selected.has(key)) selected.delete(key);
else selected.set(key, item);
return emitSelection();
},
fillSelection(limit) {
limit = Math.max(0, Math.floor(Number(limit) || 0));
if (!limit) return { selected: selected.size, added: 0, limit };
selecting = true;
const before = selected.size;
available.some(item => {
if (selected.size >= limit) return true;
selected.set(itemKey(item), item);
return false;
});
emitSelection();
return { selected: selected.size, added: selected.size - before, limit };
},
isSelected(item) {
return selected.has(itemKey(item));
},
selectedItems() {
return Array.from(selected.values());
},
selection() {
return { active: selecting, count: selected.size, ids: Array.from(selected.keys()) };
},
togglePreview(item) {
const key = itemKey(item);
if (previewed.has(key)) previewed.delete(key);
@@ -91,6 +179,8 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
pagination.total = Math.max(available.length, pagination.total - 1);
pagination.has_more = available.length < pagination.total;
previewed.delete(itemKey(item));
selected.delete(itemKey(item));
emitSelection();
onItems(available.slice());
onPagination({ ...pagination });
onStatus('Assigned ' + key + ' to you.');
@@ -101,4 +191,23 @@ function createFindWork({ fetchJson, onItems, onPagination, onStatus }) {
};
}
function attachFindWorkFacets(root, escapeHtml, onChange) {
const values = name => Array.from(root.querySelectorAll('input[name="' + name + '"]:checked'), input => input.value);
root.onchange = event => event.target.type === 'checkbox' && onChange({ repositories:values('find-work-repository'), labels:values('find-work-label') });
root.querySelector('#clear-find-work-filters').onclick = () => {
root.querySelectorAll('input').forEach(input => { input.checked = false; });
onChange({ repositories:[], labels:[] });
};
return facets => {
const active = { repositories:values('find-work-repository'), labels:values('find-work-label') };
const render = (id, choices, selected, name) => {
root.querySelector(id).innerHTML = choices.map(value => '<label class="find-work-filter-option"><input type="checkbox" name="' + name + '" value="' + escapeHtml(value) + '"' + (selected.includes(value) ? ' checked' : '') + ' />' + escapeHtml(value) + '</label>').join('');
};
render('#find-work-repository-filters', facets.repositories || [], active.repositories, 'find-work-repository');
render('#find-work-label-filters', facets.labels || [], active.labels, 'find-work-label');
const count = active.repositories.length + active.labels.length;
root.querySelector('#find-work-filter-count').textContent = count ? '(' + count + ' active)' : '';
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createFindWork;
+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;
+72 -5
View File
@@ -4,7 +4,11 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
let itemsById = new Map();
let capacityMinutes = null;
let estimates = {};
let recommendations = {};
let recommendationAware = false;
let capacityAware = false;
let buildState = null;
let buildReadiness = null;
function cleanItems(items) {
const unique = new Map();
@@ -15,7 +19,7 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
return unique;
}
function open(selectedItems, candidates, planning = null) {
function open(selectedItems, candidates, planning = null, actualMinutes = null) {
itemsById = cleanItems([...(selectedItems || []), ...(candidates || [])]);
draftIds = [];
for (const item of selectedItems || []) {
@@ -29,6 +33,15 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
for (const [id, minutes] of Object.entries(planning?.estimates || {})) {
if (Number.isInteger(minutes) && minutes > 0) estimates[id] = minutes;
}
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;
}
if (Object.keys(recommendations).length) capacityAware = true;
openState = true;
return snapshot();
}
@@ -62,7 +75,11 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
itemsById = new Map();
capacityMinutes = null;
estimates = {};
recommendations = {};
recommendationAware = false;
capacityAware = false;
buildState = null;
buildReadiness = null;
}
function cancel() {
@@ -77,13 +94,59 @@ 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;
}
function applyRecommendation(id) {
const minutes = recommendations[id];
if (!openState || !draftIds.includes(id) || !Number.isInteger(minutes)) return false;
estimates[id] = minutes;
delete recommendations[id];
capacityAware = true;
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];
@@ -111,10 +174,14 @@ function createPlanToday({ identity, save, start, limit = 5 }) {
...basic,
capacity_minutes: capacityMinutes,
estimates: selectedEstimates,
...(recommendationAware ? { recommendations:Object.fromEntries(
draftIds.filter(id => recommendations[id]).map(id => [id, recommendations[id]])
) } : {}),
planned_minutes: plannedMinutes,
remaining_minutes: remainingMinutes,
unestimated_count: draftIds.length - Object.keys(selectedEstimates).length,
over_capacity: remainingMinutes !== null && remainingMinutes < 0,
...(buildState ? { build:buildState } : {}),
};
}
@@ -126,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, 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))
+158
View File
@@ -41,10 +41,137 @@ function renderFile(file, index, reviewed, escapeHtml) {
'" aria-pressed="' + String(reviewed) + '">' + (reviewed ? 'Reviewed' : 'Mark reviewed') + '</button></article>';
}
function focusNextUnreviewed(doc, detail, state, controller) {
if (!detail || !state) return;
const filename = controller.nextUnreviewed(detail, state);
const article = Array.from(doc.querySelectorAll('#pull-files .pull-file'))
.find(file => file.dataset.pullFilename === filename);
const toggle = article?.querySelector('.pull-file-toggle');
const panel = toggle && doc.getElementById(toggle.getAttribute('aria-controls'));
if (!toggle || !panel) return;
toggle.setAttribute('aria-expanded', 'true');
panel.hidden = false;
toggle.scrollIntoView({ block: 'center', behavior: 'smooth' });
toggle.focus();
}
function removeFromSnapshot(data, item) {
return { ...data, pulls:(data.pulls || []).filter(candidate =>
candidate.repository !== item.repository || candidate.number !== item.number) };
}
function sameTarget(left, right) {
return Boolean(left && right && left.repository === right.repository && Number(left.number) === Number(right.number));
}
function ownershipSelectors() {
return ['#release-pull', '#load-pull-handoff', '#pull-handoff-recipient', '#confirm-pull-handoff'];
}
function renderHandoffCandidates(select, candidates, doc) {
select.textContent = '';
const placeholder = doc.createElement('option');
placeholder.value = '';
placeholder.textContent = candidates.length ? 'Select a teammate' : 'No eligible teammates';
select.appendChild(placeholder);
candidates.forEach(candidate => {
const option = doc.createElement('option');
option.value = candidate.login;
option.textContent = candidate.name + (candidate.name === candidate.login ? '' : ' (@' + candidate.login + ')');
select.appendChild(option);
});
return candidates.length;
}
function ownershipExitMessage(item, action, transitionResult) {
return item.key + ' ' + action + (transitionResult === 'opened' ?
'. Next work item opened.' : '. Choose the next ready Today item.');
}
function resetOwnershipControls(doc, item, checkpointed) {
const qs = selector => doc.querySelector(selector);
qs('#pull-ownership').open = false;
qs('#pull-handoff-recipient').innerHTML = '<option value="">Select a teammate</option>';
qs('#pull-handoff-recipient').disabled = true;
qs('#confirm-pull-handoff').disabled = true;
qs('#confirm-pull-handoff').textContent = checkpointed(item) ? 'Hand off & next' : 'Confirm handoff';
qs('#load-pull-handoff').disabled = false;
qs('#release-pull').disabled = false;
qs('#release-pull').textContent = checkpointed(item) ? 'Release & next' : 'Release assignment';
qs('#pull-handoff-status').textContent = 'Load teammates to transfer ownership.';
}
function bindOwnershipControls(doc, controller, getSelected, finish) {
const qs = selector => doc.querySelector(selector);
const load = qs('#load-pull-handoff');
if (load.dataset.ownershipBound === 'true') return;
load.dataset.ownershipBound = 'true';
load.addEventListener('click', async () => {
const selected = getSelected();
if (!selected) return;
const select = qs('#pull-handoff-recipient');
load.disabled = true;
qs('#pull-handoff-status').textContent = 'Loading eligible teammates…';
try {
const count = renderHandoffCandidates(select, await controller.loadHandoffCandidates(selected), doc);
select.disabled = !count;
qs('#confirm-pull-handoff').disabled = true;
qs('#pull-handoff-status').textContent = count ?
'Choose who should own this pull request next.' : 'No other eligible assignees were found.';
if (count) select.focus();
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' Retry loading teammates.';
load.disabled = false;
load.focus();
}
});
qs('#pull-handoff-recipient').addEventListener('change', event => {
qs('#confirm-pull-handoff').disabled = !event.target.value;
});
qs('#confirm-pull-handoff').addEventListener('click', async () => {
const selected = getSelected();
const recipient = qs('#pull-handoff-recipient').value;
if (!selected || !recipient || !globalThis.confirm('Hand off ' + selected.key + ' to @' + recipient + '?')) return;
const button = qs('#confirm-pull-handoff');
button.disabled = true;
qs('#pull-handoff-status').textContent = 'Confirming handoff…';
try {
await controller.handoff(selected, recipient);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' handed off to @' + recipient + '.' :
ownershipExitMessage(selected, 'handed off to @' + recipient, transition);
} catch (error) {
qs('#pull-handoff-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
qs('#release-pull').addEventListener('click', async () => {
const selected = getSelected();
if (!selected || !globalThis.confirm('Release ' + selected.key + ' from your My Work?')) return;
const button = qs('#release-pull');
button.disabled = true;
qs('#pull-sheet-status').textContent = 'Releasing assignment…';
try {
await controller.release(selected);
const transition = await finish(selected);
qs('#my-work-action-status').textContent = transition === false ?
selected.key + ' released.' : ownershipExitMessage(selected, 'released', transition);
} catch (error) {
qs('#pull-sheet-status').textContent = error.message + ' The pull request remains in My Work; retry.';
button.disabled = false;
button.focus();
}
});
}
function createPullSheet({ fetchJson, storage, createConversationPager = globalThis.createConversationPager, createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random() }) {
let commentRequest = null;
let mergeRequest = null;
let checkRequest = null;
let ownershipRequest = null;
let candidateRequest = null;
const reviewRequests = new Map();
const reviewCache = new Map();
const pathFor = item => 'api/v1/repos/' + String(item.repository || '').split('/')
@@ -90,6 +217,29 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
}).finally(() => { checkRequest = null; });
return checkRequest;
},
loadHandoffCandidates(item) {
if (candidateRequest) return candidateRequest;
candidateRequest = fetchJson(pathFor(item) + '/handoff-candidates', {
headers: { Accept: 'application/json' },
}).finally(() => { candidateRequest = null; });
return candidateRequest;
},
handoff(item, recipient) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/handoff', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient }),
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
release(item) {
if (ownershipRequest) return ownershipRequest;
ownershipRequest = fetchJson(pathFor(item) + '/release', {
method: 'PATCH', headers: { Accept: 'application/json' },
}).finally(() => { ownershipRequest = null; });
return ownershipRequest;
},
conversation(item, initialPage) {
const pager = createConversationPager({
loadPage: page => fetchJson(pathFor(item) + '/comments?page=' + encodeURIComponent(page) + '&limit=20', {
@@ -165,4 +315,12 @@ function createPullSheet({ fetchJson, storage, createConversationPager = globalT
createPullSheet.mergeEligibility = mergeEligibility;
createPullSheet.renderFile = renderFile;
createPullSheet.focusNextUnreviewed = focusNextUnreviewed;
createPullSheet.removeFromSnapshot = removeFromSnapshot;
createPullSheet.sameTarget = sameTarget;
createPullSheet.ownershipSelectors = ownershipSelectors;
createPullSheet.renderHandoffCandidates = renderHandoffCandidates;
createPullSheet.ownershipExitMessage = ownershipExitMessage;
createPullSheet.resetOwnershipControls = resetOwnershipControls;
createPullSheet.bindOwnershipControls = bindOwnershipControls;
if (typeof module !== 'undefined' && module.exports) module.exports = createPullSheet;
+189
View File
@@ -0,0 +1,189 @@
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.attachSecurityCenter = boundary => factory({ root, boundary });
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
let activityCursor = null;
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
try {
const devices = await boundary.listActiveDevices();
devices.forEach(device => {
const row = root.document.createElement('article');
row.className = 'active-device';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = device.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
details.append(label, timing);
if (device.current) {
const current = root.document.createElement('span');
current.className = 'active-device-current';
current.textContent = 'This device';
details.append(current);
}
const revoke = root.document.createElement('button');
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
});
row.append(details, revoke);
devicesList.append(row);
});
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
} catch (_error) {
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderPasskeys = async () => {
if (!passkeysList || !passkeysStatus) return;
passkeysStatus.textContent = 'Loading enrolled passkeys…';
passkeysList.replaceChildren();
try {
const enrolled = await boundary.listPasskeys();
enrolled.forEach(passkey => {
const row = root.document.createElement('article');
row.className = 'enrolled-passkey';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = passkey.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
details.append(label, timing);
const remove = root.document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove passkey';
remove.addEventListener('click', async () => {
remove.disabled = true;
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
try {
const outcome = await boundary.revokePasskey(passkey);
if (outcome) {
passkeysStatus.textContent = outcome.current_session
? 'Passkey removed. This session remains active; keep your recovery token available.'
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
}
} catch (_error) {
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
remove.disabled = false;
}
});
row.append(details, remove);
passkeysList.append(row);
});
passkeysStatus.textContent = enrolled.length
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
: 'No passkeys enrolled.';
} catch (_error) {
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
loadMoreActivity.hidden = true;
if (!append) {
activityCursor = null;
activityList.replaceChildren();
}
try {
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
if (!append) {
page.authentication_alerts.forEach(alert => {
const row = root.document.createElement('article');
row.className = 'security-event authentication-alert';
const formatted = boundary.formatAuthenticationAlert(alert);
const title = root.document.createElement('strong');
title.textContent = formatted.title;
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
activityList.append(row);
});
}
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',
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
};
page.events.forEach(event => {
const row = root.document.createElement('article');
row.className = 'security-event';
const title = root.document.createElement('strong');
title.textContent = labels[event.kind] || 'Security event';
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 : ''}`;
row.append(title, details);
activityList.append(row);
});
activityCursor = page.next_cursor;
activityStatus.textContent = activityList.children.length
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
: 'No security activity yet.';
loadMoreActivity.textContent = 'Load older activity';
loadMoreActivity.hidden = !activityCursor;
} catch (_error) {
activityStatus.textContent = 'Security activity could not be loaded.';
loadMoreActivity.textContent = 'Retry activity';
loadMoreActivity.hidden = false;
}
};
const open = () => {
if (!devicesSheet) return;
devicesSheet.hidden = false;
root.document.getElementById('close-active-devices')?.focus();
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
};
devicesButton?.addEventListener('click', open);
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
enrollPasskey?.addEventListener('click', async () => {
enrollPasskey.disabled = true;
devicesStatus.textContent = 'Waiting for your device passkey…';
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
enrollPasskey.disabled = false;
}
});
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
});
+143 -18
View File
@@ -1,9 +1,10 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v87';
const CACHE = 'stackchain-dashboard-shell-v98';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
const SHELL = [
BASE,
BASE + 'manifest.webmanifest',
@@ -13,12 +14,15 @@ const SHELL = [
BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js',
BASE + 'static/feature-loader.js',
BASE + 'static/security-center.js',
BASE + 'static/markdown.js',
BASE + 'static/commands.js',
BASE + 'static/search-preview.js',
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',
@@ -28,23 +32,32 @@ 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',
BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js',
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',
BASE + 'static/detail-defer.js',
BASE + 'static/later-picker.js',
BASE + 'static/pick-work.js',
BASE + 'static/batch-find-work.js',
BASE + 'static/conversation.js',
BASE + 'static/comment-actions.js',
BASE + 'static/issue-attachment.js',
BASE + 'static/issue-sheet.js',
BASE + 'static/create-issue-sheet.js',
@@ -58,14 +71,18 @@ 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',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',
BASE + 'static/background-issue-sync.js',
];
const OPTIONAL_FEATURES = [
];
async function fetchNavigation(request) {
const controller = new AbortController();
@@ -110,8 +127,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))
@@ -148,6 +178,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))
@@ -241,10 +272,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 => {
@@ -262,6 +299,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.') });
@@ -275,26 +313,109 @@ self.addEventListener('push', event => {
catch (_error) { return; }
const route = String(payload.route || '');
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
&& Number.isSafeInteger(updateCount)
&& updateCount > 0
&& updateCount <= 50
) {
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
body: 'Tap to review them in Stackchain.',
tag,
data: {route},
}));
return;
}
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
event.waitUntil(self.registration.showNotification('New work update', {
const options = {
body: 'Tap to review it in Stackchain.',
tag,
data: {route},
}));
};
if (
Number.isSafeInteger(notificationId) && notificationId > 0
&& route === '#/my-work/update/' + notificationId
&& tag === 'stackchain-update-' + notificationId
) {
options.actions = [
{ action: 'mark-read', title: 'Mark read' },
{ action: 'tomorrow', title: 'Tomorrow' },
];
options.data.notificationId = notificationId;
}
event.waitUntil(self.registration.showNotification('New work update', options));
});
self.addEventListener('notificationclick', event => {
event.notification.close();
const route = String(event.notification.data?.route || '');
if (!route.startsWith('#/my-work/')) return;
async function openWorkRoute(route) {
const target = new URL(BASE + route, self.location.origin).href;
event.waitUntil((async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
})());
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE));
if (!client) return self.clients.openWindow(target);
if (client.navigate) await client.navigate(target);
return client.focus();
}
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
if (event.action === 'tomorrow') {
const notificationId = Number(event.notification.data?.notificationId);
if (
!Number.isSafeInteger(notificationId) || notificationId <= 0
|| route !== '#/my-work/update/' + notificationId
|| event.notification.tag !== 'stackchain-update-' + notificationId
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
const wake = new Date(self.__STACKCHAIN_NOW?.() || Date.now());
wake.setDate(wake.getDate() + 1);
wake.setHours(9, 0, 0, 0);
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/later', {
method: 'PATCH',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ wake_at: wake.toISOString() }),
signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
if (event.action === 'mark-read') {
const notificationId = Number(event.notification.data?.notificationId);
if (
!Number.isSafeInteger(notificationId) || notificationId <= 0
|| route !== '#/my-work/update/' + notificationId
) return;
event.waitUntil((async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PUSH_ACTION_TIMEOUT_MS);
try {
await fetchJson(BASE + 'api/v1/notifications/' + notificationId + '/read', {
method: 'PATCH', headers: { Accept: 'application/json' }, signal: controller.signal,
});
event.notification.close();
} catch (_error) {
await openWorkRoute(route);
event.notification.close();
} finally {
clearTimeout(timeout);
}
})());
return;
}
event.notification.close();
if (!route.startsWith('#/my-work/')) return;
event.waitUntil(openWorkRoute(route));
});
self.addEventListener('fetch', event => {
@@ -328,5 +449,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)));
}
});
+29 -177
View File
@@ -36,191 +36,42 @@
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
const devicesButton = root.document.getElementById('active-devices');
const devicesSheet = root.document.getElementById('active-devices-sheet');
const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status');
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const activityList = root.document.getElementById('security-activity-list');
const activityStatus = root.document.getElementById('security-activity-status');
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
let activityCursor = null;
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
try {
const devices = await boundary.listActiveDevices();
devices.forEach(device => {
const row = root.document.createElement('article');
row.className = 'active-device';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = device.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
details.append(label, timing);
if (device.current) {
const current = root.document.createElement('span');
current.className = 'active-device-current';
current.textContent = 'This device';
details.append(current);
}
const revoke = root.document.createElement('button');
revoke.type = 'button';
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
revoke.addEventListener('click', async () => {
if (device.current) await boundary.signOut();
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
});
row.append(details, revoke);
devicesList.append(row);
});
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
} catch (_error) {
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderPasskeys = async () => {
if (!passkeysList || !passkeysStatus) return;
passkeysStatus.textContent = 'Loading enrolled passkeys…';
passkeysList.replaceChildren();
try {
const enrolled = await boundary.listPasskeys();
enrolled.forEach(passkey => {
const row = root.document.createElement('article');
row.className = 'enrolled-passkey';
const details = root.document.createElement('div');
const label = root.document.createElement('strong');
label.textContent = passkey.device_label;
const timing = root.document.createElement('span');
timing.className = 'small muted';
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
details.append(label, timing);
const remove = root.document.createElement('button');
remove.type = 'button';
remove.textContent = 'Remove passkey';
remove.addEventListener('click', async () => {
remove.disabled = true;
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
try {
const outcome = await boundary.revokePasskey(passkey);
if (outcome) {
passkeysStatus.textContent = outcome.current_session
? 'Passkey removed. This session remains active; keep your recovery token available.'
: 'Passkey removed.';
await renderPasskeys();
if (outcome.session_revoked) await renderDevices();
} else {
remove.disabled = false;
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
}
} catch (_error) {
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
remove.disabled = false;
}
});
row.append(details, remove);
passkeysList.append(row);
});
passkeysStatus.textContent = enrolled.length
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
: 'No passkeys enrolled.';
} catch (_error) {
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
loadMoreActivity.hidden = true;
if (!append) {
activityCursor = null;
activityList.replaceChildren();
}
try {
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
if (!append) {
page.authentication_alerts.forEach(alert => {
const row = root.document.createElement('article');
row.className = 'security-event authentication-alert';
const formatted = boundary.formatAuthenticationAlert(alert);
const title = root.document.createElement('strong');
title.textContent = formatted.title;
const details = root.document.createElement('span');
details.className = 'small muted';
details.textContent = formatted.detail;
row.append(title, details);
activityList.append(row);
});
}
const labels = {
sign_in: 'Signed in',
sign_out: 'Signed out',
passkey_enrolled: 'Passkey enrolled',
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',
pull_review_changes_requested: 'Changes requested',
};
page.events.forEach(event => {
const row = root.document.createElement('article');
row.className = 'security-event';
const title = root.document.createElement('strong');
title.textContent = labels[event.kind] || 'Security event';
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [
event.device_label,
event.method,
event.target,
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 : ''}`;
row.append(title, details);
activityList.append(row);
});
activityCursor = page.next_cursor;
activityStatus.textContent = activityList.children.length
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
: 'No security activity yet.';
loadMoreActivity.textContent = 'Load older activity';
loadMoreActivity.hidden = !activityCursor;
} catch (_error) {
activityStatus.textContent = 'Security activity could not be loaded.';
loadMoreActivity.textContent = 'Retry activity';
loadMoreActivity.hidden = false;
}
};
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
const securityFeatures = root.createFeatureLoader({
document: root.document,
urls: {
'security-center': root.document.querySelector(
'meta[name="stackchain-feature-security-center"]'
)?.content || '',
},
});
let loadingSecurityCenter = false;
const openSecurityCenter = async () => {
if (loadingSecurityCenter) return;
loadingSecurityCenter = true;
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
renderPasskeys();
renderSecurityActivity();
});
try {
await securityFeatures.run('security-center', {
trigger: devicesButton,
status: devicesStatus,
retryLabel: 'Tap Active devices to retry.',
}, () => {
devicesButton.removeEventListener('click', openSecurityCenter);
root.attachSecurityCenter(boundary).open();
});
} finally {
loadingSecurityCenter = false;
}
};
if (devicesButton && devicesSheet) {
devicesButton.addEventListener('click', openSecurityCenter);
}
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
devicesButton?.focus();
});
enrollPasskey?.addEventListener('click', async () => {
enrollPasskey.disabled = true;
devicesStatus.textContent = 'Waiting for your device passkey…';
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
await renderPasskeys();
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
enrollPasskey.disabled = false;
}
});
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
});
@@ -605,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);
+369
View File
@@ -0,0 +1,369 @@
function todayRecapFeedbackRows(draft, describe = () => null) {
return (draft?.items || []).map(item => {
const work = describe(item.identity) || {};
const estimate = Number.isInteger(item.estimate_minutes) ? item.estimate_minutes : null;
return {
identity:item.identity,
label:String(work.title || item.identity).slice(0, 180),
context:String(work.key || work.repository || 'Work details unavailable').slice(0, 180),
estimate_minutes:estimate,
actual_minutes:item.actual_minutes,
variance_minutes:estimate === null ? null : item.actual_minutes - estimate,
};
});
}
function createTodayRecap({ save, saveAndLog = null, clear, makeId = () => crypto.randomUUID(), storage = null, getLogin = () => '' }) {
const totals = items => {
const estimated = items.reduce((sum, item) => sum + (item.estimate_minutes ?? 0), 0);
const actual = items.reduce((sum, item) => sum + item.actual_minutes, 0);
return { estimated_minutes:estimated, actual_minutes:actual, variance_minutes:actual - estimated };
};
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-draft.v1.' + login : '';
};
const handoffKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-recap-handoff.v1.' + login : '';
};
const validStoredDraft = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!Array.isArray(saved.items) || !saved.items.length || saved.items.length > 20) return false;
const itemsValid = saved.items.every(item => item && typeof item.identity === 'string' && item.identity.length > 0 &&
item.identity.length <= 500 && (item.estimate_minutes === null ||
(Number.isInteger(item.estimate_minutes) && item.estimate_minutes >= 5 && item.estimate_minutes <= 1440)) &&
Number.isInteger(item.actual_minutes) && item.actual_minutes >= 0 && item.actual_minutes <= 1440);
if (!itemsValid) return false;
const identities = new Set(saved.items.map(item => item.identity));
if (saved.log_identities !== undefined && (!Array.isArray(saved.log_identities) ||
saved.log_identities.length > 20 || saved.log_identities.some(identity => !identities.has(identity)))) return false;
if (saved.time_logs !== undefined && (!saved.time_logs || typeof saved.time_logs !== 'object' ||
Array.isArray(saved.time_logs) || Object.entries(saved.time_logs).some(([identity, status]) =>
!identities.has(identity) || !['logged', 'retry', 'verify'].includes(status)))) return false;
return true;
};
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validStoredDraft(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { ...saved, ...totals(saved.items) };
}
catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const validHandoff = saved => {
if (!saved || typeof saved.session_id !== 'string' || !saved.session_id.length || saved.session_id.length > 100 ||
!saved.actual_minutes || typeof saved.actual_minutes !== 'object' || Array.isArray(saved.actual_minutes)) return false;
const entries = Object.entries(saved.actual_minutes);
return entries.length > 0 && entries.length <= 20 && entries.every(([identity, minutes]) =>
identity.length > 0 && identity.length <= 500 && Number.isInteger(minutes) && minutes >= 0 && minutes <= 1440
);
};
const pendingReplan = () => {
const key = handoffKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!validHandoff(saved)) {
if (saved !== null) storage.removeItem(key);
return null;
}
return { session_id:saved.session_id, actual_minutes:{...saved.actual_minutes} };
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const completeReplan = () => {
const key = handoffKey();
if (storage && key) storage.removeItem(key);
};
let draftKey = storageKey();
let draft = load();
const persist = () => {
const key = storageKey();
if (!storage || !key || !draft) return;
const saved = { session_id:draft.session_id, items:draft.items };
if (draft.log_identities) saved.log_identities = draft.log_identities;
if (draft.time_logs) saved.time_logs = draft.time_logs;
storage.setItem(key, JSON.stringify(saved));
};
const restore = () => {
const key = storageKey();
if (key !== draftKey) {
draft = null;
draftKey = key;
}
if (!draft) draft = load();
return Boolean(draft);
};
const snapshot = () => {
restore();
return draft ? {
...draft, items:draft.items.map(item => ({...item})),
...(draft.log_identities ? {log_identities:[...draft.log_identities]} : {}),
...(draft.time_logs ? {time_logs:{...draft.time_logs}} : {}),
} : null;
};
const saveConfirmed = async includeActuals => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
const payload = { session_id:draft.session_id, items:draft.items.map(item => ({...item})) };
const actualMinutes = includeActuals ? Object.fromEntries(
payload.items.map(item => [item.identity, item.actual_minutes])
) : null;
const result = await save(payload);
if (includeActuals && storage && handoffKey()) storage.setItem(handoffKey(), JSON.stringify({
session_id:payload.session_id, actual_minutes:actualMinutes,
}));
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return includeActuals ? { result, actual_minutes:actualMinutes } : result;
};
const saveWithTime = async identities => {
restore();
if (!draft?.items.length) throw new Error('No timed work to save.');
if (typeof saveAndLog !== 'function') throw new Error('Gitea time logging is unavailable.');
const requested = new Set(Array.isArray(identities) ? identities : []);
const eligible = new Set(draft.items.filter(item => item.actual_minutes > 0).map(item => item.identity));
const intended = [...new Set([...(draft.log_identities || []), ...requested])]
.filter(identity => eligible.has(identity));
if (!intended.length) throw new Error('Select at least one non-zero time entry.');
draft.log_identities = intended;
draft.time_logs = draft.time_logs || {};
persist();
const logIdentities = intended.filter(identity => !['logged', 'verify'].includes(draft.time_logs[identity]));
const payload = {
session_id:draft.session_id,
items:draft.items.map(item => ({...item})),
log_identities:logIdentities,
};
const result = await saveAndLog(payload);
Object.assign(draft.time_logs, Object.fromEntries((result.time_logs || []).map(item => [item.identity, item.status])));
persist();
const logged = intended.filter(identity => draft.time_logs[identity] === 'logged').length;
const verify = intended.filter(identity => draft.time_logs[identity] === 'verify').length;
const retry = intended.length - logged - verify;
if (retry || verify) {
if (verify) throw new Error(verify + ' time ' + (verify === 1 ? 'entry needs' : 'entries need') +
' verification in Gitea; it will not be posted again automatically.');
throw new Error(logged + ' time ' + (logged === 1 ? 'entry' : 'entries') + ' logged; ' + retry + ' needs retry.');
}
clear();
if (storage && draftKey) storage.removeItem(draftKey);
draft = null;
return result;
};
return {
restore,
begin(entries, estimates = {}) {
const items = (entries || []).filter(entry => entry?.identity).map(entry => ({
identity:String(entry.identity),
estimate_minutes:Number.isInteger(estimates[entry.identity]) ? estimates[entry.identity] : null,
actual_minutes:Math.min(1440, Math.max(0, Math.round(Number(entry.elapsed_ms || 0) / 60000))),
}));
draftKey = storageKey();
draft = { session_id:makeId(), items, ...totals(items) };
persist();
return snapshot();
},
correct(identity, minutes) {
restore();
if (!draft || !Number.isInteger(minutes) || minutes < 0 || minutes > 1440) return false;
const item = draft.items.find(candidate => candidate.identity === identity);
if (!item) return false;
item.actual_minutes = minutes;
Object.assign(draft, totals(draft.items));
persist();
return true;
},
snapshot,
save:() => saveConfirmed(false),
saveForReplan:() => saveConfirmed(true),
saveAndLog:saveWithTime,
pendingReplan,
completeReplan,
discardReplan:completeReplan,
};
}
async function saveTodayRecap(payload) {
const response = await fetch('api/v1/today/recaps', {
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.detail || 'Recap could not be saved.');
return result;
}
async function saveTodayRecapTime(payload) {
const response = await fetch('api/v1/today/recaps/log-time', {
method:'POST', headers:{ Accept:'application/json', 'Content-Type':'application/json' },
body:JSON.stringify(payload),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.detail || 'Recap time could not be logged.');
return result;
}
function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, escapeHtml,
describeWork = () => null, adjustPlan = () => {} }) {
const minutes = value => String(Math.max(0, Number(value) || 0)) + 'm';
const render = () => {
const draft = recap.snapshot();
const container = qs('#today-recap-items');
const rows = todayRecapFeedbackRows(draft, describeWork);
container.innerHTML = rows.map(item => {
const variance = item.variance_minutes === null ? 'No estimate comparison' :
(item.variance_minutes === 0 ? 'On estimate' : minutes(Math.abs(item.variance_minutes)) +
(item.variance_minutes > 0 ? ' over estimate' : ' under estimate'));
const canonical = /^(issue|pull):[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+:[1-9][0-9]*:$/.test(item.identity);
const logStatus = draft?.time_logs?.[item.identity];
const logControl = logStatus === 'logged' ?
'<span class="today-recap-log today-recap-log-success">Logged to Gitea</span>' :
logStatus === 'verify' ? '<span class="today-recap-log today-recap-log-verify">Verify this entry in Gitea</span>' :
item.actual_minutes > 0 && canonical ?
'<label class="today-recap-log"><input type="checkbox" data-recap-log-identity="' +
escapeHtml(item.identity) + '"> ' + (logStatus === 'retry' ? 'Retry ' : 'Log ') +
minutes(item.actual_minutes) + ' to Gitea</label>' :
'<span class="small muted today-recap-log">No non-zero Gitea time to log</span>';
return '<div class="today-recap-row"><span><strong>' + escapeHtml(item.label) + '</strong><span class="small today-recap-context">' +
escapeHtml(item.context) + ' · ' + (item.estimate_minutes === null ? 'Not estimated' : minutes(item.estimate_minutes) + ' estimated') +
'</span></span><span><input type="number" inputmode="numeric" min="0" max="1440" step="1" value="' +
item.actual_minutes + '" data-recap-identity="' + escapeHtml(item.identity) + '" aria-label="Actual minutes for ' +
escapeHtml(item.label) + '"> min</span><span class="today-recap-variance">' + variance + '</span>' + logControl + '</div>';
}).join('') || '';
qs('#today-recap-totals').textContent = draft ? minutes(draft.estimated_minutes) + ' estimated · ' +
minutes(draft.actual_minutes) + ' actual · ' + minutes(Math.abs(draft.variance_minutes)) +
(draft.variance_minutes >= 0 ? ' over' : ' under') : '';
container.querySelectorAll('[data-recap-identity]').forEach(input => input.addEventListener('change', () => {
if (!recap.correct(input.dataset.recapIdentity, Number(input.value))) {
qs('#today-recap-status').textContent = 'Actual time must be a whole number from 0 to 1,440 minutes.';
}
render();
}));
};
const loadHistory = async () => {
qs('#today-recap-history').innerHTML = '<div class="small">Loading recent recaps…</div>';
try {
const result = await api('api/v1/today/recaps');
qs('#today-recap-history').innerHTML = (result.recaps || []).map(saved =>
'<div class="today-recap-history-row"><span>' + new Date(saved.created_at * 1000).toLocaleString() +
'</span><strong>' + minutes(saved.actual_minutes) + ' actual · ' + minutes(Math.abs(saved.variance_minutes)) +
(saved.variance_minutes >= 0 ? ' over' : ' under') + '</strong></div>'
).join('') || '<div class="small">No saved recaps yet.</div>';
} catch (_error) {
qs('#today-recap-history').innerHTML = '<div class="small">Recent recaps are unavailable. Try again.</div>';
}
};
const open = ({ begin = false } = {}) => {
if (begin) recap.begin(timer.recapEntries(), todayWork.planning().estimates);
else recap.restore();
render();
qs('#save-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#save-log-today-recap').hidden = !recap.snapshot()?.items.length;
qs('#today-recap-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
loadHistory();
requestAnimationFrame(() => (qs('#today-recap-items input') || qs('#close-today-recap')).focus());
};
const close = () => {
qs('#today-recap-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#open-today-recaps').focus();
};
const saveDraft = async button => {
button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…';
try {
const handoff = await recap.saveForReplan();
qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(handoff.actual_minutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; }
};
const saveTimeDraft = async button => {
const selected = Array.from(qs('#today-recap-items').querySelectorAll('[data-recap-log-identity]:checked'))
.map(input => input.dataset.recapLogIdentity);
if (!selected.length) {
qs('#today-recap-status').textContent = 'Select at least one non-zero time entry.';
return;
}
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…';
try {
await recap.saveAndLog(selected);
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true;
close();
adjustPlan(actualMinutes);
} catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render();
} finally { button.disabled = false; }
};
const bind = () => {
qs('#open-today-recaps').addEventListener('click', () => open());
qs('#close-today-recap').addEventListener('click', close);
qs('#discard-today-recap').addEventListener('click', close);
qs('#save-today-recap').addEventListener('click', event => saveDraft(event.currentTarget));
qs('#save-log-today-recap').addEventListener('click', event => saveTimeDraft(event.currentTarget));
};
return {
open, close, saveDraft, saveTimeDraft, loadHistory, render, bind,
pendingReplan:recap.pendingReplan,
completeReplan:recap.completeReplan,
discardReplan:recap.discardReplan,
};
}
function endTodaySession(workSession, qs) {
workSession.end();
qs('#my-work-action-status').textContent = 'Today session ended. Your plan is unchanged.';
}
function openTodayRecapAfterSession(view, timer, timerView, workFilter, qs) {
timerView.finish();
document.querySelectorAll('.work-session-nav').forEach(nav => { nav.hidden = true; });
qs('#my-work-action-status').textContent = 'Work session complete.';
if (workFilter === 'today' && timer.recapEntries().length) view.open({ begin:true });
else qs('#start-work-session').focus();
}
function setupTodayRecap(timer, timerView, todayWork, api, qs, escapeHtml, closeSheets, updateActions, getLogin,
describeWork, adjustPlan) {
const options = { timer, timerView, todayWork, api, qs, escapeHtml, describeWork, adjustPlan };
const recap = createTodayRecap({
save:saveTodayRecap, saveAndLog:saveTodayRecapTime,
clear:() => timer.clearRecap(), storage:localStorage, getLogin,
});
const view = createTodayRecapView({ ...options, recap });
view.bind();
setInterval(timerView.render, 1000);
view.finish = workFilter => {
closeSheets();
updateActions();
openTodayRecapAfterSession(view, timer, timerView, workFilter, qs);
};
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodayRecap.createView = createTodayRecapView;
createTodayRecap.feedbackRows = todayRecapFeedbackRows;
module.exports = createTodayRecap;
}
+38
View File
@@ -0,0 +1,38 @@
function createTodayRollover(options = {}) {
const now = options.now || (() => new Date());
const resolvedTimeZone = options.resolvedTimeZone || (() =>
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
function timeZone() {
return options.timeZone?.() || resolvedTimeZone();
}
function localDate() {
if (options.localDate) return options.localDate();
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(now());
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${value.year}-${value.month}-${value.day}`;
}
function reviewState(plan) {
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
if (!plan.plan_date) return 'legacy';
return plan.plan_date === localDate() ? 'current' : 'stale';
}
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
const selected = new Set(ids);
return {
operation_id, action: 'rollover', item_id: 'plan', base_revision,
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
};
}
return { localDate, timeZone, reviewState, operation };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
+32 -10
View File
@@ -55,20 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
function adopt(plan, broadcast = true) {
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
if (plan.revision < savedRevision()) return false;
const snapshot = {
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
};
if (plan.plan_date) {
snapshot.plan_date = plan.plan_date;
snapshot.timezone = plan.timezone || null;
}
try {
storage?.setItem(snapshotKey(), JSON.stringify({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
}));
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
} catch (_error) {
// A storage quota failure must not prevent the current tab from using server truth.
}
onRemoteIds?.(plan.ids);
onRemotePlan?.(plan);
if (broadcast) channel?.postMessage({
revision: plan.revision, ids: plan.ids,
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
});
if (broadcast) channel?.postMessage(snapshot);
return true;
}
@@ -129,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
? record.operation.base_revision : Math.max(0, savedRevision()),
}))
.filter(operation => operation && typeof operation.operation_id === 'string' &&
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
} catch (_error) {
return [];
}
@@ -201,6 +203,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
}
}
function enqueueRollover(proposed) {
if (!proposed || proposed.action !== 'rollover') return false;
const operation = {
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
};
const storageKey = key();
if (!storageKey || !storage) return false;
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
try {
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
knownOperationKeys.add(recordKey);
coordinator?.notify('today');
onStatus?.('pending');
return true;
} catch (_error) {
onStatus?.('error');
return false;
}
}
function migrate(ids) {
const storageKey = key();
if (!storageKey || !storage) return false;
@@ -281,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
if (change.queue === 'today' && pending().length) flush();
});
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
+374
View File
@@ -0,0 +1,374 @@
function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
const key = () => {
const login = String(getLogin?.() || '').trim().toLowerCase();
return login ? 'stackchain.today-timer.v1.' + encodeURIComponent(login) : '';
};
const empty = () => ({
version:1, active_identity:'', entries:{}, away_at:null,
pending_interruption:null, attention_interruption:null,
});
const read = () => {
const ownerKey = key();
if (!ownerKey || !storage) return empty();
try {
const value = JSON.parse(storage.getItem(ownerKey) || 'null');
if (value?.version !== 1 || typeof value.active_identity !== 'string' ||
!value.entries || typeof value.entries !== 'object') return empty();
return value;
} catch (_error) {
return empty();
}
};
const write = state => {
const ownerKey = key();
if (!ownerKey || !storage) return false;
try {
storage.setItem(ownerKey, JSON.stringify(state));
return true;
} catch (_error) {
return false;
}
};
const validPending = state => {
const pending = state.pending_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
Number.isFinite(pending.away_ms) && pending.away_ms >= 0 ?
{ identity:pending.identity, away_ms:pending.away_ms } : null;
};
const validAttention = state => {
const pending = state.attention_interruption;
return pending && typeof pending.identity === 'string' && pending.identity &&
typeof pending.resume === 'boolean' ?
{ identity:pending.identity, resume:pending.resume } : null;
};
const settle = (state, at = now()) => {
const entry = state.entries[state.active_identity];
if (!entry?.running) return state;
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) || 0) +
Math.max(0, at - Number(entry.started_at ?? at));
entry.started_at = null;
entry.running = false;
return state;
};
const snapshot = (identity = '') => {
const state = read();
const selected = identity || state.active_identity;
const entry = state.entries[selected];
if (!selected || !entry) return { identity:selected, elapsed_ms:0, running:false };
const elapsed = Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0);
return { identity:selected, elapsed_ms:elapsed, running:Boolean(entry.running) };
};
return {
activate(identity) {
if (!key() || typeof identity !== 'string' || !identity) return false;
const state = read();
if (state.active_identity === identity && state.entries[identity]?.running) return true;
settle(state);
state.active_identity = identity;
const entry = state.entries[identity] || { elapsed_ms:0, started_at:null, running:false };
entry.started_at = now();
entry.running = true;
state.entries[identity] = entry;
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
pause() {
const state = read();
settle(state);
state.away_at = null;
return write(state);
},
resume() {
const state = read();
const entry = state.entries[state.active_identity];
if (!entry) return false;
if (!entry.running) {
entry.started_at = now();
entry.running = true;
}
return write(state);
},
stop() {
const state = read();
settle(state);
state.away_at = null;
state.pending_interruption = null;
state.attention_interruption = null;
return write(state);
},
beginAttention() {
const state = read();
const existing = validAttention(state);
if (existing) return existing;
const identity = state.active_identity;
const entry = state.entries[identity];
if (!identity || !entry) return null;
const resume = Boolean(entry.running);
if (resume) settle(state);
state.away_at = null;
state.attention_interruption = { identity, resume };
return write(state) ? { ...state.attention_interruption } : null;
},
attentionInterruption() {
return validAttention(read());
},
returnFromAttention() {
const state = read();
const pending = validAttention(state);
const entry = pending && state.entries[pending.identity];
if (!pending || !entry) return null;
state.active_identity = pending.identity;
if (pending.resume && !entry.running) {
entry.started_at = now();
entry.running = true;
}
state.attention_interruption = null;
state.away_at = null;
return write(state) ? { identity:pending.identity, resumed:pending.resume } : null;
},
markAway() {
const state = read();
const entry = state.entries[state.active_identity];
if (!entry?.running || validPending(state)) return false;
if (Number.isFinite(state.away_at) && state.away_at >= 0) return true;
state.away_at = now();
return write(state);
},
reconcileInterruption(thresholdMs = 5 * 60 * 1000) {
const state = read();
const existing = validPending(state);
if (existing) return existing;
state.pending_interruption = null;
const entry = state.entries[state.active_identity];
const awayAt = Number(state.away_at);
const detectedAt = now();
state.away_at = null;
if (!entry?.running || !Number.isFinite(awayAt) || awayAt < 0 || detectedAt < awayAt ||
detectedAt - awayAt < thresholdMs) {
write(state);
return null;
}
const awayMs = detectedAt - awayAt;
settle(state, detectedAt);
state.pending_interruption = { identity:state.active_identity, away_ms:awayMs };
write(state);
return { ...state.pending_interruption };
},
pendingInterruption() {
return validPending(read());
},
resolveInterruption(decision) {
if (decision !== 'count' && decision !== 'exclude') return false;
const state = read();
const pending = state.pending_interruption;
const entry = pending && state.entries[pending.identity];
if (!entry || !Number.isFinite(pending.away_ms) || pending.away_ms < 0) return false;
if (decision === 'exclude') {
entry.elapsed_ms = Math.max(0, Number(entry.elapsed_ms) - pending.away_ms);
}
state.active_identity = pending.identity;
entry.started_at = now();
entry.running = true;
state.pending_interruption = null;
state.away_at = null;
return write(state);
},
recapEntries() {
const state = read();
settle(state);
write(state);
return Object.entries(state.entries).map(([identity, entry]) => ({
identity,
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0),
})).filter(entry => entry.elapsed_ms > 0);
},
clearRecap() {
return write(empty());
},
totalElapsed() {
const state = read();
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
const live = identity === state.active_identity && entry.running ?
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
}, 0);
},
snapshot,
};
}
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
let progress = null;
let runway = null;
queryAll('[data-mobile-today-open]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onReopen?.(identity);
})
);
queryAll('[data-mobile-today-toggle]').forEach(button =>
button.addEventListener('click', () => view.toggle())
);
queryAll('[data-mobile-today-complete]').forEach(button =>
button.addEventListener('click', () => {
const identity = timer.snapshot().identity;
if (identity) onComplete?.(identity);
})
);
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
const button = document.createElement('button');
button.type = 'button';
button.hidden = true;
button.dataset.workSessionAdjustPlan = '';
button.textContent = 'Adjust remaining plan';
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
});
if (typeof MutationObserver !== 'undefined') {
const plan = queryAll('#plan-today')[0];
const sheet = queryAll('#plan-today-sheet')[0];
if (plan && sheet) {
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
queryAll('[data-work-session-adjust-plan]').forEach(button =>
button.addEventListener('click', () => replan.open())
);
new MutationObserver(() => {
if (sheet.hidden && replan.restore()) render();
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
}
}
const elapsed = milliseconds => {
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = String(seconds % 60).padStart(2, '0');
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
};
const render = () => {
const snapshot = timer.snapshot();
const active = Boolean(progress && isActive() && snapshot.identity);
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
queryAll('[data-mobile-today-open]').forEach(element => {
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
});
queryAll('[data-mobile-today-complete]').forEach(button => {
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
const finalItem = Boolean(progress && progress.index >= progress.total);
button.hidden = !active;
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
button.setAttribute('aria-label', finalItem ?
'Complete ' + itemTitle + ' and open Today recap' :
'Complete ' + itemTitle + ' and open next Today item');
});
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
if (!progress) return;
const sourceRunway = getRunway?.(snapshot) || runway;
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
const currentRemaining = sourceRunway.current_minutes === null ? null :
Math.max(0, sourceRunway.current_minutes - currentElapsed);
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
currentRemaining + sourceRunway.future_minutes;
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
sourceRunway.capacity_minutes - projected;
return {
...sourceRunway,
remaining_minutes:remaining,
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
Math.max(0, currentElapsed - sourceRunway.current_minutes),
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
};
})() : sourceRunway;
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
const estimateRisk = liveRunway?.over_estimate_minutes ?
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
const capacityRisk = liveRunway?.over_capacity_minutes ?
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
queryAll('[data-work-session-progress]').forEach(element => {
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
});
queryAll('[data-work-session-adjust-plan]').forEach(button => {
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
});
queryAll('[data-work-session-timer-toggle]').forEach(button => {
button.hidden = !isActive();
button.textContent = snapshot.running ? 'Pause timer' : 'Resume timer';
button.setAttribute('aria-pressed', String(!snapshot.running));
});
};
return {
open(identity, active) {
const current = timer.snapshot();
if (active && current.identity !== identity) timer.activate(identity);
else if (!active) timer.stop();
render();
},
finish() { timer.stop(); progress = null; runway = null; render(); },
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
reset() { progress = null; runway = null; render(); },
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
render,
};
}
function createTodayBudgetReplan({ timer, openPlan }) {
let active = false;
let resume = false;
return {
open() {
if (active) return false;
const state = timer.snapshot();
if (!state.identity) return false;
resume = Boolean(state.running);
if (resume && timer.pause() === false) return false;
active = true;
openPlan?.(state);
return true;
},
restore() {
if (!active) return false;
active = false;
const shouldResume = resume;
resume = false;
return !shouldResume || timer.resume() !== false;
},
};
}
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
const render = pending => {
if (!pending) {
sheet.hidden = true;
return false;
}
const label = String(getItemLabel?.(pending.identity) || 'Current Today item');
const minutes = Math.max(1, Math.round(pending.away_ms / 60000));
description.textContent = label + ' · away for ' + minutes + ' minute' + (minutes === 1 ? '' : 's');
sheet.hidden = false;
return true;
};
return {
background() { return timer.markAway(); },
foreground() { return render(timer.reconcileInterruption()); },
restore() { return render(timer.pendingInterruption()); },
resolve(decision) {
if (!timer.resolveInterruption(decision)) return false;
sheet.hidden = true;
onResolved?.();
return true;
},
};
}
if (typeof module !== 'undefined' && module.exports) {
createTodayTimer.createView = createTodayTimerView;
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
module.exports = createTodayTimer;
}
+23 -5
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 || []) {
@@ -150,16 +165,19 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
}
function runway(items, currentIndex = 0) {
const estimates = planning().estimates;
const remaining = (items || []).slice(Math.max(0, currentIndex));
const minutes = remaining.map(item => estimates[identity(item)] || null);
const plan = planning();
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
const total = values => values.some(value => value === null) ? null :
values.reduce((sum, value) => sum + value, 0);
return {
current_minutes: minutes[0] || null,
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
remaining_minutes: total(minutes),
future_minutes: total(minutes.slice(1)),
capacity_minutes: plan.capacity_minutes,
};
}
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 {
+92
View File
@@ -0,0 +1,92 @@
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 selectMany(items) {
if (!active) return { status: 'inactive', added: 0, count: selected.size, limit: maximum };
let added = 0;
let capped = false;
for (const item of items || []) {
const id = identity(item);
if (!id || selected.has(id)) continue;
if (selected.size >= maximum) {
capped = true;
break;
}
selected.add(id);
added += 1;
}
changed();
return { status: capped ? 'limit' : 'selected', added, count: selected.size, limit: maximum };
}
function clear() {
if (!active) return snapshot();
selected.clear();
return changed();
}
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, selectMany, clear, toggle, retain, snapshot, limit: maximum };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createWorkSelection;
+32 -2
View File
@@ -10,6 +10,7 @@ import os
import secrets
import time
from dataclasses import dataclass
from urllib.parse import urlsplit
from fastapi import Request
@@ -47,6 +48,33 @@ def mode() -> str:
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
def public_origin() -> str | None:
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
if not configured:
return None
try:
parsed = urlsplit(configured)
port = parsed.port
except ValueError:
return None
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
return None
hostname = parsed.hostname.lower()
authority = f"[{hostname}]" if ":" in hostname else hostname
if port is not None and port != 443:
authority += f":{port}"
return f"https://{authority}"
def configuration_error() -> str | None:
configured_mode = mode()
if configured_mode == INSECURE_LOCAL_MODE:
@@ -59,6 +87,8 @@ def configuration_error() -> str | None:
return "operator mode requires two sufficiently long secrets"
if hmac.compare_digest(operator_secret, signing_secret):
return "operator and session secrets must be independent"
if public_origin() is None:
return "operator mode requires a canonical HTTPS public origin"
return None
@@ -274,5 +304,5 @@ def application_path(request: Request) -> str:
def same_origin(request: Request) -> bool:
origin = request.headers.get("origin", "")
expected = f"{request.url.scheme}://{request.url.netloc}"
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
expected = public_origin()
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
+29 -4
View File
@@ -13,13 +13,26 @@ import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
COMMONJS_EXPORT_LINE = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$", re.MULTILINE
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*$(?!\n\s*else)", re.MULTILINE
)
COMMONJS_BROWSER_BRANCH = re.compile(
rb"^\s*if \(typeof module[^\n]+module\.exports[^\n]+;\s*\n\s*else\s*\{",
re.MULTILINE,
)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"issue-capture": ("static/create-issue-sheet.js",),
"comment-actions": ("static/conversation.js", "static/comment-actions.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/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
),
}
CACHE_DECLARATION = re.compile(
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
@@ -55,6 +68,7 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
source = b"".join(chunks)
# Node-only export shims support source-level unit tests but are unreachable
# in the browser. Strip the simple one-line form from shipped bundles.
source = COMMONJS_BROWSER_BRANCH.sub(b"{", source)
source = COMMONJS_EXPORT_LINE.sub(b"", source)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()
@@ -93,18 +107,29 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
)
dashboard_html = dashboard_html.replace("</head>", feature_metadata + "\n</head>")
dashboard_html = dashboard_html.replace(
"</body>", f'<script src="{core.runtime_name}"></script>\n</body>'
"</body>",
f'<script src="{feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{core.runtime_name}"></script>\n</body>',
)
worker = (frontend_dir / "service-worker.js").read_text()
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
+226
View File
@@ -20,6 +20,10 @@ class GiteaOverloadedError(RuntimeError):
"""Raised when bounded transport admission expires before capacity is available."""
class CommentMutationForbiddenError(RuntimeError):
"""Raised when a comment is not owned by the operator or enclosing thread."""
class GiteaTransport:
"""Application-lifetime HTTP transport with single-flight concurrent reads."""
@@ -210,6 +214,35 @@ async def fetch(path: str) -> Any:
return r.json()
def issue_time_target(identity: str) -> tuple[str, str]:
"""Return the canonical repository and issue number encoded by an identity."""
match = re.fullmatch(
r"(?:issue|pull):([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+):([1-9][0-9]*):",
identity,
)
if match is None:
raise ValueError("time log target is invalid")
return match.groups()
async def log_issue_time(identity: str, seconds: int) -> None:
"""Log time to the canonical issue endpoint encoded by a dashboard identity."""
repository, number = issue_time_target(identity)
if not isinstance(seconds, int) or isinstance(seconds, bool) or not 1 <= seconds <= 86400:
raise ValueError("time log duration is invalid")
response = await _get_client().post(
f"/api/v1/repos/{repository}/issues/{number}/times",
headers={**_auth(), "Content-Type": "application/json"},
json={"time": seconds},
)
response.raise_for_status()
def time_log_failure_is_retryable(error: Exception) -> bool:
"""Return true only when failure proves no successful response was lost."""
return isinstance(error, (ValueError, GiteaOverloadedError, httpx.ConnectError, httpx.HTTPStatusError))
async def fetch_text(path: str, max_bytes: int) -> tuple[str, bool]:
chunks: list[bytes] = []
size = 0
@@ -709,6 +742,41 @@ async def notifications() -> dict:
return await notification_page(1)
async def unread_notification_snapshot(
*,
limit: int = 50,
max_pages: int = 20,
max_concurrency: int = 4,
deadline_seconds: float = 5.0,
) -> dict:
"""Load one complete, bounded snapshot of unread notification threads."""
async with asyncio.timeout(deadline_seconds):
first = await notification_page(1, limit)
page_count = max(1, (first["total"] + limit - 1) // limit)
if page_count > max_pages:
raise ValueError("Unread notification snapshot exceeds the scan limit")
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def load(page: int) -> dict:
async with semaphore:
return await notification_page(page, limit)
remaining = await asyncio.gather(
*(load(page) for page in range(2, page_count + 1))
)
pages = [first, *remaining]
if any(page["total"] != first["total"] for page in pages[1:]):
raise ValueError("Unread notification pagination changed during the scan")
items = [item for page in pages for item in page["items"]]
thread_ids = [
item.get("id") for item in items
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
]
if len(items) != first["total"] or len(set(thread_ids)) != first["total"]:
raise ValueError("Unread notification snapshot has an incomplete thread set")
return {"items": items, "total": first["total"], "complete": True}
async def notification_page(page: int, limit: int = 50) -> dict:
response = await _get_client().get(
f"/api/v1/notifications?status-types=unread&limit={limit}&page={page}",
@@ -736,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):
@@ -928,6 +1004,25 @@ async def notification_conversation_page(
)
async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
raise ValueError("Gitea notification thread response was not an object")
repository = thread.get("repository")
subject_value = thread.get("subject")
subject = subject_value if isinstance(subject_value, dict) else {}
repository_name = repository.get("full_name") if isinstance(repository, dict) else None
subject_path = _gitea_api_path(subject.get("url"))
match = re.fullmatch(r"repos/([^/]+/[^/]+)/(issues|pulls)/(\d+)", subject_path)
if (
not match
or match.group(1) != repository_name
or subject.get("type") not in {"Issue", "Pull"}
):
raise ValueError("Notification subject is not a supported conversation")
return match.group(1), int(match.group(3))
async def reply_to_notification(thread_id: int, body: str) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -1042,6 +1137,51 @@ async def comment_on_issue(repository: str, number: int, body: str) -> dict:
return _normalize_issue_comment(comment)
async def _owned_comment(repository: str, number: int, comment_id: int) -> dict:
user, comment = await asyncio.gather(
current_user(),
fetch(f"repos/{repository}/issues/comments/{comment_id}"),
)
login = user.get("login") if isinstance(user, dict) else None
author = comment.get("user") if isinstance(comment, dict) else None
expected_issue_path = f"repos/{repository}/issues/{number}"
if (
not isinstance(login, str)
or not login
or not isinstance(comment, dict)
or not isinstance(author, dict)
or author.get("login") != login
or _gitea_api_path(comment.get("issue_url")) != expected_issue_path
):
raise CommentMutationForbiddenError("Comment is not editable in this conversation")
return comment
async def edit_owned_comment(
repository: str, number: int, comment_id: int, body: str
) -> dict:
await _owned_comment(repository, number, comment_id)
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/comments/{comment_id}",
headers=_auth(),
json={"body": body},
)
response.raise_for_status()
comment = response.json()
if not isinstance(comment, dict):
raise ValueError("Gitea comment response was not an object")
return _normalize_issue_comment(comment)
async def delete_owned_comment(repository: str, number: int, comment_id: int) -> dict:
await _owned_comment(repository, number, comment_id)
response = await _get_client().delete(
f"/api/v1/repos/{repository}/issues/comments/{comment_id}", headers=_auth()
)
response.raise_for_status()
return {"id": comment_id, "deleted": True}
async def upload_assigned_issue_attachment(
repository: str,
number: int,
@@ -1498,6 +1638,92 @@ async def handoff_assigned_issue(
}
async def pull_handoff_candidates(repository: str) -> list[dict]:
candidates = await issue_handoff_candidates(repository)
return [
item for item in candidates
if re.fullmatch(r"[A-Za-z0-9_.-]+", item["login"])
][:25]
async def _change_assigned_pull_owners(
repository: str, number: int, recipient: str | None
) -> dict:
login, pull = await _current_login_and_target(
f"repos/{repository}/pulls/{number}"
)
assignees_value = pull.get("assignees")
assignees = assignees_value if isinstance(assignees_value, list) else []
login_folded = login.casefold()
assigned_to_login = any(
isinstance(item, dict)
and isinstance(item.get("login"), str)
and item["login"].casefold() == login_folded
for item in assignees
)
if (
pull.get("state") != "open"
or pull.get("merged") is True
or not assigned_to_login
):
raise IssueNotAvailableError("Pull request is not assigned to the current user")
if recipient is not None:
eligible = {
item["login"] for item in await pull_handoff_candidates(repository)
}
if recipient not in eligible:
raise IssueNotAvailableError("Handoff recipient is not eligible")
desired = [
item["login"] for item in assignees
if isinstance(item, dict)
and isinstance(item.get("login"), str)
and item["login"].casefold() != login_folded
]
if recipient is not None and recipient not in desired:
desired.append(recipient)
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
headers=_auth(),
json={"assignees": desired},
)
response.raise_for_status()
confirmed = response.json()
confirmed_value = confirmed.get("assignees") if isinstance(confirmed, dict) else None
confirmed_assignees = [
item["login"] for item in confirmed_value
if isinstance(item, dict) and isinstance(item.get("login"), str)
] if isinstance(confirmed_value, list) else []
if (
not isinstance(confirmed, dict)
or confirmed.get("number") != number
or login_folded in {item.casefold() for item in confirmed_assignees}
or set(confirmed_assignees) != set(desired)
or (recipient is not None and recipient not in confirmed_assignees)
):
raise ValueError("Gitea did not confirm pull request ownership change")
result = {
"repository": repository,
"number": number,
"state": confirmed.get("state", "open"),
"assignees": confirmed_assignees,
}
if recipient is not None:
result["recipient"] = recipient
return result
async def handoff_assigned_pull(
repository: str, number: int, recipient: str
) -> dict:
return await _change_assigned_pull_owners(repository, number, recipient)
async def release_assigned_pull(repository: str, number: int) -> dict:
return await _change_assigned_pull_owners(repository, number, None)
async def update_issue_labels(repository: str, number: int, label_ids: list[int]) -> dict:
response = await _get_client().patch(
f"/api/v1/repos/{repository}/issues/{number}",
+567 -18
View File
@@ -13,7 +13,7 @@ from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlencode
from urllib.parse import urlencode, urlsplit
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
from fastapi.exceptions import RequestValidationError
@@ -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
@@ -87,16 +88,25 @@ async def _push_poll_loop() -> None:
send_timeout + 5.0,
float(os.getenv("STACKCHAIN_PUSH_LEASE_SECONDS", "60")),
)
max_concurrency = max(
1, int(os.getenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "8"))
)
max_individual_notifications = max(
0,
int(os.getenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "3")),
)
while True:
await asyncio.sleep(interval)
try:
await dispatch_unread_updates(
_push_subscription_store,
_push_configuration(),
notifications,
gitea_proxy.unread_notification_snapshot,
session_active=dashboard_auth.managed_session_active,
lease_seconds=lease_seconds,
send_timeout_seconds=send_timeout,
max_concurrency=max_concurrency,
max_individual_notifications=max_individual_notifications,
)
except asyncio.CancelledError:
raise
@@ -270,6 +280,7 @@ StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
"close_issue",
"log_recap_time",
"revoke_device",
"revoke_all_sessions",
"enroll_passkey",
@@ -330,10 +341,12 @@ def _security_event_store() -> SecurityEventStore:
def _passkey_relying_party(request: Request) -> tuple[str, str]:
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
canonical_origin = dashboard_auth.public_origin()
canonical_host = urlsplit(canonical_origin).hostname if canonical_origin else None
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", canonical_host or request.url.hostname or "")
origin = os.getenv(
"STACKCHAIN_PASSKEY_ORIGIN",
f"{request.url.scheme}://{request.url.netloc}",
canonical_origin or f"{request.url.scheme}://{request.url.netloc}",
)
return rp_id, origin
@@ -385,12 +398,15 @@ class NotificationReadBatch(BaseModel):
class TodayOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
action: Literal["add", "remove", "move", "configure"]
action: Literal["add", "remove", "move", "configure", "rollover"]
item_id: str = Field(min_length=1, max_length=500)
direction: Literal["up", "down"] | None = None
base_revision: int | None = Field(default=None, ge=0)
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
timezone: str | None = Field(default=None, min_length=1, max_length=100)
ids: list[str] = Field(default_factory=list, max_length=5)
@model_validator(mode="after")
def validate_action_fields(self):
@@ -398,13 +414,58 @@ class TodayOperation(BaseModel):
raise ValueError("move requires a direction")
if self.action != "move" and self.direction is not None:
raise ValueError("direction is only valid for move")
if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
raise ValueError("capacity and estimates are only valid for configure")
if self.action not in {"configure", "rollover"} and (
self.capacity_minutes is not None or self.estimates
):
raise ValueError("capacity and estimates are only valid for configure or rollover")
if self.action == "rollover":
if self.item_id != "plan" or self.plan_date is None or self.timezone is None:
raise ValueError("rollover requires plan date, timezone, and plan item")
if len(set(self.ids)) != len(self.ids):
raise ValueError("rollover IDs must be unique")
elif self.plan_date is not None or self.timezone is not None or self.ids:
raise ValueError("date, timezone, and IDs are only valid for rollover")
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
for item_id, minutes in self.estimates.items()):
raise ValueError("estimates must use bounded item IDs and minutes")
return self
def model_dump(self, *args, **kwargs):
data = super().model_dump(*args, **kwargs)
if self.action != "rollover":
data.pop("plan_date", None)
data.pop("timezone", None)
data.pop("ids", None)
return data
class TodayRecapItem(BaseModel):
identity: str = Field(min_length=1, max_length=500)
estimate_minutes: int | None = Field(default=None, ge=5, le=1440)
actual_minutes: int = Field(ge=0, le=1440)
class TodayRecap(BaseModel):
session_id: str = Field(min_length=1, max_length=100)
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
class TodayRecapTimeLog(TodayRecap):
log_identities: list[str] = Field(min_length=1, max_length=20)
def _recap_time_log_target(payload: TodayRecapTimeLog) -> str:
selected_minutes = {
item.identity: item.actual_minutes
for item in payload.items
if item.identity in payload.log_identities
}
material = "\0".join(
[payload.session_id]
+ [f"{identity}\0{selected_minutes.get(identity, '')}" for identity in sorted(payload.log_identities)]
)
return f"today-recap:{hashlib.sha256(material.encode()).hexdigest()[:32]}"
class LaterOperation(BaseModel):
operation_id: str = Field(min_length=1, max_length=100)
@@ -422,6 +483,10 @@ class LaterOperationBatch(BaseModel):
operations: list[LaterOperation] = Field(min_length=1, max_length=50)
class NotificationLaterRequest(BaseModel):
wake_at: str = Field(min_length=1, max_length=100)
class NotificationReply(BaseModel):
body: str = Field(min_length=1, max_length=10_000)
@@ -824,6 +889,15 @@ def _share_target_login_redirect(request: Request) -> str:
@app.middleware("http")
async def require_operator_session(request: Request, call_next):
path = dashboard_auth.application_path(request)
canonical_origin = dashboard_auth.public_origin()
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE and canonical_origin:
expected_authority = urlsplit(canonical_origin).netloc
if request.url.netloc.lower() != expected_authority:
return JSONResponse(
{"detail": "Request host does not match the configured public origin"},
status_code=421,
headers={"Cache-Control": "no-store"},
)
if path == "/healthz":
return await call_next(request)
@@ -981,6 +1055,8 @@ async def enforce_browser_security_boundary(request: Request, call_next):
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
)
response.headers["X-Frame-Options"] = "DENY"
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE:
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
@@ -1520,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,
@@ -1611,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(
@@ -1713,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
)
@@ -1722,7 +1822,9 @@ async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
payload.model_dump(),
)
try:
current = await asyncio.wait_for(notifications(), NOTIFICATION_PAGE_TIMEOUT_SECONDS)
current = await gitea_proxy.unread_notification_snapshot(
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
)
except Exception as error:
await asyncio.to_thread(_push_subscription_store.delete_session, device_id)
raise HTTPException(
@@ -1730,13 +1832,13 @@ async def subscribe_push(payload: PushSubscriptionPayload, request: Request):
detail="Unread updates are temporarily unavailable",
headers={"Retry-After": "1"},
) from error
existing_ids = {
int(item["notification_id"])
existing_revisions = {
int(item["id"]): str(item.get("updated_at") or "")
for item in current.get("items", [])
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
await asyncio.to_thread(
_push_subscription_store.mark_delivered, device_id, existing_ids
_push_subscription_store.mark_delivered, device_id, existing_revisions
)
return {"subscribed": True}
@@ -1853,6 +1955,149 @@ async def update_today_plan(payload: TodayOperation | TodayOperationBatch):
)
@app.get("/api/v1/today/recaps")
async def get_today_recaps(response: Response):
login = await _confirmed_login()
try:
recaps = await asyncio.to_thread(_today_store().list_recaps, login)
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today recap history is unavailable",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return {"recaps": recaps}
@app.post("/api/v1/today/recaps")
async def save_today_recap(payload: TodayRecap, response: Response):
login = await _confirmed_login()
try:
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
payload.session_id,
[item.model_dump() for item in payload.items],
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
raise HTTPException(
status_code=503,
detail="Today recap could not be saved",
headers={"Retry-After": "1"},
)
response.headers["Cache-Control"] = "no-store"
return recap
@app.post("/api/v1/today/recaps/log-time")
async def save_today_recap_and_log_time(
payload: TodayRecapTimeLog,
request: Request,
response: Response,
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
login = await _confirmed_login()
if len(set(payload.log_identities)) != len(payload.log_identities):
raise HTTPException(status_code=422, detail="time log targets must be unique")
requested_items = {item.identity: item for item in payload.items}
selected = []
try:
for identity in payload.log_identities:
gitea_proxy.issue_time_target(identity)
requested = requested_items.get(identity)
if requested is None:
raise ValueError("time log target must match the recap")
if requested.actual_minutes <= 0:
raise ValueError("only non-zero recap time can be logged")
selected.append(requested)
await _require_step_up(
request,
step_up_grant,
action="log_recap_time",
target=_recap_time_log_target(payload),
)
recap = await asyncio.to_thread(
_today_store().save_recap,
login,
payload.session_id,
[item.model_dump() for item in payload.items],
)
saved_items = {item["identity"]: item for item in recap["items"]}
for requested in selected:
saved = saved_items.get(requested.identity)
if saved is None or saved["actual_minutes"] != requested.actual_minutes:
raise ValueError("time log target must match the saved recap")
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except (OSError, sqlite3.Error):
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
semaphore = asyncio.Semaphore(3)
async def log_selected_time(item):
async with semaphore:
result = {"identity": item.identity, "status": "retry"}
try:
state = await asyncio.to_thread(
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
)
if state == "claimed":
repository, number = gitea_proxy.issue_time_target(item.identity)
journal = _security_event_store()
try:
operation_id = await asyncio.to_thread(
journal.reserve,
"gitea_time_logged",
target=f"{repository}#{number}",
)
except SecurityEventStoreError:
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
return result
try:
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
except Exception as error:
if gitea_proxy.time_log_failure_is_retryable(error):
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=False,
)
try:
await asyncio.to_thread(journal.discard, operation_id)
except SecurityEventStoreError:
pass
status = "retry"
else:
status = "verify"
return {"identity": item.identity, "status": status}
await asyncio.to_thread(
_today_store().finish_time_log,
login, payload.session_id, item.identity, succeeded=True,
)
try:
await asyncio.to_thread(journal.finalize, operation_id)
except SecurityEventStoreError:
pass
state = "logged"
return {
"identity": item.identity,
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
}
except (ValueError, OSError, sqlite3.Error):
return result
results = await asyncio.gather(*(log_selected_time(item) for item in selected))
response.headers["Cache-Control"] = "no-store"
return {**recap, "time_logs": results}
@app.get("/api/v1/later")
async def get_later_plan():
login = await _confirmed_login()
@@ -2476,19 +2721,53 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
raise
def _available_issue_page(items: list[dict], page: int, limit: int = 50) -> dict:
def _available_issue_page(
items: list[dict], page: int, limit: int = 50, query: str = "",
repositories: list[str] | None = None, labels: list[str] | None = None,
include_facets: bool = False,
) -> dict:
facets = {
"repositories": sorted({str(item.get("repository")) for item in items if item.get("repository")}, key=str.casefold),
"labels": sorted({str(label) for item in items for label in (item.get("labels") or [])}, key=str.casefold),
}
repository_filter = {value.casefold() for value in (repositories or [])}
label_filter = {value.casefold() for value in (labels or [])}
if repository_filter:
items = [item for item in items if str(item.get("repository") or "").casefold() in repository_filter]
if label_filter:
items = [item for item in items if label_filter & {str(label).casefold() for label in (item.get("labels") or [])}]
normalized_query = query.strip().casefold()
if normalized_query:
number_query = normalized_query.removeprefix("#")
items = [
item for item in items
if normalized_query in str(item.get("repository") or "").casefold()
or normalized_query in str(item.get("title") or "").casefold()
or (number_query.isdigit() and number_query == str(item.get("number") or ""))
]
start = (page - 1) * limit
page_items = items[start:start + limit]
return {
result = {
"items": page_items,
"page": page,
"total": len(items),
"has_more": start + len(page_items) < len(items),
}
if include_facets or repository_filter or label_filter:
result["facets"] = facets
return result
@app.get("/api/v1/available-issues")
async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONResponse:
async def available_issues(
page: int = Query(default=1, ge=1, le=100),
q: str = Query(default="", max_length=100),
repository: list[str] = Query(default=[], max_length=100),
label: list[str] = Query(default=[], max_length=100),
facets: bool = Query(default=False),
) -> JSONResponse:
if len(repository) > 10 or len(label) > 10:
return JSONResponse({"error": "Select at most 10 repositories or labels."}, status_code=422)
try:
items, stale, revalidating, refresh_failed = await asyncio.wait_for(
_available_issue_snapshot(), timeout=WORK_PAGE_TIMEOUT_SECONDS
@@ -2499,7 +2778,9 @@ async def available_issues(page: int = Query(default=1, ge=1, le=100)) -> JSONRe
status_code=503,
headers={"Retry-After": str(math.ceil(WORK_PAGE_TIMEOUT_SECONDS))},
)
result = _available_issue_page(items, page)
result = _available_issue_page(
items, page, query=q, repositories=repository, labels=label, include_facets=facets
)
if stale:
result["stale"] = True
if revalidating:
@@ -3212,6 +3493,78 @@ 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,
thread_id: int = PathParam(gt=0),
) -> JSONResponse:
login = await _confirmed_login()
try:
item = await asyncio.wait_for(
gitea_proxy.resolve_work_route("update", None, None, thread_id),
timeout=NOTIFICATION_DETAIL_TIMEOUT_SECONDS,
)
repository = item.get("repository")
if not isinstance(repository, str) or not repository:
raise ValueError("The update has no repository identity")
item_id = f"update:{repository}::{thread_id}"
result = await asyncio.to_thread(
_later_store().apply,
login,
f"push-tomorrow:{thread_id}:{payload.wake_at}",
"defer",
item_id,
wake_at=payload.wake_at,
)
except gitea_proxy.WorkRouteUnavailableError:
return JSONResponse(
{"error": "This update is no longer available to defer."},
status_code=409,
)
except ValueError as error:
raise HTTPException(status_code=422, detail=str(error))
except TimeoutError:
return JSONResponse(
{"error": "Deferring the update timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except (OSError, sqlite3.Error):
return JSONResponse(
{"error": "Later synchronization is unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse({
"id": thread_id,
"status": "deferred",
"item_id": item_id,
"wake_at": payload.wake_at,
"revision": result["revision"],
})
@app.post("/api/v1/notifications/{thread_id}/acknowledge")
async def acknowledge_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
@@ -3852,6 +4205,131 @@ async def comment_on_assigned_issue(
return JSONResponse(result, status_code=201)
async def _edit_conversation_comment(
repository: str, number: int, comment_id: int, body: str
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.edit_owned_comment(repository, number, comment_id, body),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(
status_code=403, detail="You can only change your own comments"
) from exc
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The comment could not be updated. Your edit is safe; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
async def _delete_conversation_comment(
repository: str, number: int, comment_id: int
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.delete_owned_comment(repository, number, comment_id),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.CommentMutationForbiddenError as exc:
raise HTTPException(
status_code=403, detail="You can only change your own comments"
) from exc
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "The comment deletion could not be confirmed. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
async def edit_assigned_issue_comment(
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
@app.delete("/api/v1/repos/{owner}/{repo}/issues/{number}/comments/{comment_id}")
async def delete_assigned_issue_comment(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_issue(repository, number):
raise HTTPException(status_code=404, detail="Assigned issue not found")
return await _delete_conversation_comment(repository, number, comment_id)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
async def edit_assigned_pull_comment(
comment: IssueComment,
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
@app.delete("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments/{comment_id}")
async def delete_assigned_pull_comment(
owner: str,
repo: str,
number: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
repository = f"{owner}/{repo}"
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
return await _delete_conversation_comment(repository, number, comment_id)
@app.patch("/api/v1/notifications/{thread_id}/comments/{comment_id}")
async def edit_notification_comment(
comment: IssueComment,
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _edit_conversation_comment(repository, number, comment_id, comment.body)
@app.delete("/api/v1/notifications/{thread_id}/comments/{comment_id}")
async def delete_notification_comment(
thread_id: int = PathParam(gt=0),
comment_id: int = PathParam(gt=0),
):
try:
repository, number = await gitea_proxy.notification_conversation_target(thread_id)
except Exception as exc:
raise HTTPException(status_code=404, detail="Notification conversation not found") from exc
return await _delete_conversation_comment(repository, number, comment_id)
@app.post("/api/v1/repos/{owner}/{repo}/issues/{number}/attachments", status_code=201)
async def attach_to_assigned_issue(
request: Request,
@@ -4258,6 +4736,77 @@ async def assigned_pull_checks(
)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff-candidates")
async def pull_handoff_candidates(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
repository = f"{owner}/{repo}"
try:
if not await gitea_proxy.is_assigned_pull(repository, number):
raise HTTPException(status_code=404, detail="Assigned pull request not found")
result = await asyncio.wait_for(
gitea_proxy.pull_handoff_candidates(repository),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except HTTPException:
raise
except Exception:
return JSONResponse(
{"error": "Teammates could not be loaded. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/handoff")
async def handoff_assigned_pull(
handoff: IssueHandoff,
owner: str,
repo: str,
number: int = PathParam(gt=0),
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.handoff_assigned_pull(
f"{owner}/{repo}", number, handoff.recipient
),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
return JSONResponse(
{"error": "The pull request or recipient changed. Reload before handing off."},
status_code=409,
)
except Exception:
return JSONResponse(
{"error": "The handoff could not be confirmed. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.patch("/api/v1/repos/{owner}/{repo}/pulls/{number}/release")
async def release_assigned_pull(
owner: str, repo: str, number: int = PathParam(gt=0)
) -> JSONResponse:
try:
result = await asyncio.wait_for(
gitea_proxy.release_assigned_pull(f"{owner}/{repo}", number),
timeout=ISSUE_ACTION_TIMEOUT_SECONDS,
)
except gitea_proxy.IssueNotAvailableError:
raise HTTPException(status_code=404, detail="Assigned pull request not found")
except Exception:
return JSONResponse(
{"error": "The assignment could not be released. It remains in My Work; please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/repos/{owner}/{repo}/pulls/{number}/comments")
async def assigned_pull_conversation(
owner: str,
+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
+156 -43
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,
)
@@ -43,6 +50,9 @@ async def dispatch_unread_updates(
session_active: Callable[[str], Awaitable[bool]] | None = None,
lease_seconds: float = 60.0,
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
@@ -57,12 +67,15 @@ async def dispatch_unread_updates(
return 0
try:
page = await unread()
thread_ids = {
int(item["notification_id"])
if page.get("complete") is False:
return 0
thread_revisions = {
int(item["id"]): str(item.get("updated_at") or "")
for item in page.get("items", [])
if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit()
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
}
deliveries = await asyncio.to_thread(store.claim_unseen, thread_ids)
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
if session_active is not None:
try:
authorized = [await session_active(item.session_id) for item in deliveries]
@@ -78,46 +91,146 @@ async def dispatch_unread_updates(
for delivery, active in zip(deliveries, authorized)
if active
]
count = 0
for delivery in deliveries:
for thread_id in delivery.thread_ids:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
now=time.time(),
lease_seconds=lease_seconds,
)
if not still_owner:
return count
payload = json.dumps(
{
"title": "New work update",
"body": "Tap to review it in Stackchain.",
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
},
separators=(",", ":"),
)
semaphore = asyncio.Semaphore(max(1, max_concurrency))
ownership_lost = asyncio.Event()
async def dispatch_device(delivery) -> int:
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 send is None:
operation = send_web_push(
delivery.subscription, payload, configuration
)
else:
operation = send(delivery.subscription, payload)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(getattr(error, "response", None), "status_code", None)
if status in {404, 410}:
await asyncio.to_thread(store.delete_session, delivery.session_id)
break
# A transient provider failure belongs to this endpoint;
# leave it unseen for a later poll and continue fan-out.
continue
await asyncio.to_thread(
store.mark_delivered, delivery.session_id, (thread_id,)
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(
thread_revision
for thread_revision in delivery.thread_revisions
if thread_revision not in digest_pending
)
count += 1
return count
individual_revisions = new_revisions[
:max(0, max_individual_notifications)
]
overflow_revisions = (
delivery.digest_revisions
+ new_revisions[len(individual_revisions):]
)
can_send_digest = True
for thread_id, revision in individual_revisions:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
now=time.time(),
lease_seconds=lease_seconds,
)
if not still_owner:
ownership_lost.set()
return count
payload = json.dumps(
{
"title": "New work update",
"body": "Tap to review it in Stackchain.",
"route": f"#/my-work/update/{thread_id}",
"tag": f"stackchain-update-{thread_id}",
"notification_id": thread_id,
},
separators=(",", ":"),
)
try:
if send is None:
operation = send_web_push(
delivery.subscription, payload, configuration
)
else:
operation = send(delivery.subscription, payload)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
can_send_digest = False
# Leave this device's transient failures unseen for a later
# poll instead of paying the endpoint deadline repeatedly.
break
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
((thread_id, revision),),
)
count += 1
if overflow_revisions and can_send_digest:
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
now=time.time(),
lease_seconds=lease_seconds,
)
if not still_owner:
ownership_lost.set()
return count
payload = json.dumps(
{
"title": f"{len(overflow_revisions)} new work updates",
"body": "Tap to review them in Stackchain.",
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": len(overflow_revisions),
},
separators=(",", ":"),
)
try:
if send is None:
operation = send_web_push(
delivery.subscription, payload, configuration
)
else:
operation = send(delivery.subscription, payload)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception as error:
status = getattr(
getattr(error, "response", None), "status_code", None
)
if status in {404, 410}:
await asyncio.to_thread(
store.delete_session, delivery.session_id
)
else:
await asyncio.to_thread(
store.mark_digest_pending,
delivery.session_id,
overflow_revisions,
)
return count
await asyncio.to_thread(
store.mark_delivered,
delivery.session_id,
overflow_revisions,
)
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_safely(delivery) for delivery in deliveries)
)
return sum(counts)
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner)
+165 -11
View File
@@ -1,16 +1,45 @@
import json
import os
import sqlite3
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
@dataclass(frozen=True)
class PushDelivery:
session_id: str
subscription: dict
thread_ids: tuple[int, ...]
thread_revisions: tuple[tuple[int, str], ...]
digest_revisions: tuple[tuple[int, str], ...] = ()
@property
def thread_ids(self) -> tuple[int, ...]:
return tuple(thread_id for thread_id, _revision in self.thread_revisions)
@property
def digest_ids(self) -> tuple[int, ...]:
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
if isinstance(values, Mapping):
items = list(values.items())
else:
items = []
for value in values:
if isinstance(value, tuple):
items.append(value)
else:
items.append((value, ""))
normalized = {}
for thread_id, revision in items:
thread_id = int(thread_id)
if thread_id > 0:
normalized[thread_id] = str(revision or "")
return tuple(sorted(normalized.items()))
class PushSubscriptionStore:
@@ -31,6 +60,7 @@ class PushSubscriptionStore:
CREATE TABLE IF NOT EXISTS push_deliveries (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
@@ -40,8 +70,31 @@ class PushSubscriptionStore:
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS push_digest_pending (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
revision TEXT NOT NULL DEFAULT '',
PRIMARY KEY (session_id, thread_id),
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
row[1] for row in connection.execute("PRAGMA table_info(push_deliveries)")
}
if "revision" not in delivery_columns:
connection.execute(
"ALTER TABLE push_deliveries ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
)
digest_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(push_digest_pending)")
}
if "revision" not in digest_columns:
connection.execute(
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
)
os.chmod(self.path, 0o600)
def _connect(self):
@@ -101,8 +154,10 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None
def claim_unseen(self, thread_ids: Iterable[int]) -> list[PushDelivery]:
candidates = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
candidates = _revisions(thread_revisions)
if not candidates:
return []
with self._connect() as connection:
@@ -112,20 +167,119 @@ class PushSubscriptionStore:
deliveries = []
for session_id, encoded in rows:
delivered = {
row[0]
row[0]: row[1]
for row in connection.execute(
"SELECT thread_id FROM push_deliveries WHERE session_id = ?",
"SELECT thread_id, revision FROM push_deliveries WHERE session_id = ?",
(session_id,),
)
}
unseen = tuple(value for value in candidates if value not in delivered)
for thread_id, revision in candidates:
if delivered.get(thread_id) == "*":
connection.execute(
"""UPDATE push_deliveries SET revision = ?
WHERE session_id = ? AND thread_id = ?""",
(revision, session_id, thread_id),
)
delivered[thread_id] = revision
unseen = tuple(
candidate
for candidate in candidates
if (
candidate[0] not in delivered
or (
bool(candidate[1])
and (
not delivered[candidate[0]]
or candidate[1] > delivered[candidate[0]]
)
)
)
)
if unseen:
deliveries.append(PushDelivery(session_id, json.loads(encoded), unseen))
pending = {
row[0]: row[1]
for row in connection.execute(
"SELECT thread_id, revision FROM push_digest_pending WHERE session_id = ?",
(session_id,),
)
}
digest_revisions = []
for thread_id, revision in unseen:
if pending.get(thread_id) in {revision, "*"}:
digest_revisions.append((thread_id, revision))
if pending[thread_id] == "*":
connection.execute(
"""UPDATE push_digest_pending SET revision = ?
WHERE session_id = ? AND thread_id = ?""",
(revision, session_id, thread_id),
)
deliveries.append(
PushDelivery(
session_id,
json.loads(encoded),
unseen,
tuple(digest_revisions),
)
)
return deliveries
def mark_delivered(self, session_id: str, thread_ids: Iterable[int]) -> None:
def reconcile_unread(self, thread_ids: Iterable[int]) -> None:
"""Prune per-device checkpoints that are absent from a complete snapshot."""
unread_ids = tuple(sorted({int(value) for value in thread_ids if int(value) > 0}))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"CREATE TEMP TABLE current_unread(thread_id INTEGER PRIMARY KEY)"
)
connection.executemany(
"INSERT INTO current_unread(thread_id) VALUES (?)",
((thread_id,) for thread_id in unread_ids),
)
connection.execute(
"""DELETE FROM push_deliveries
WHERE NOT EXISTS (
SELECT 1 FROM current_unread
WHERE current_unread.thread_id = push_deliveries.thread_id
)"""
)
connection.execute(
"""DELETE FROM push_digest_pending
WHERE NOT EXISTS (
SELECT 1 FROM current_unread
WHERE current_unread.thread_id = push_digest_pending.thread_id
)"""
)
def mark_digest_pending(
self,
session_id: str,
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> None:
values = _revisions(thread_revisions)
with self._connect() as connection:
connection.executemany(
"INSERT OR IGNORE INTO push_deliveries(session_id, thread_id) VALUES (?, ?)",
((session_id, int(thread_id)) for thread_id in thread_ids),
"""INSERT INTO push_digest_pending(session_id, thread_id, revision)
VALUES (?, ?, ?)
ON CONFLICT(session_id, thread_id) DO UPDATE SET
revision = excluded.revision""",
((session_id, thread_id, revision) for thread_id, revision in values),
)
def mark_delivered(
self,
session_id: str,
thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> None:
values = _revisions(thread_revisions)
with self._connect() as connection:
connection.executemany(
"""INSERT INTO push_deliveries(session_id, thread_id, revision)
VALUES (?, ?, ?)
ON CONFLICT(session_id, thread_id) DO UPDATE SET
revision = excluded.revision""",
((session_id, thread_id, revision) for thread_id, revision in values),
)
connection.executemany(
"DELETE FROM push_digest_pending WHERE session_id = ? AND thread_id = ?",
((session_id, thread_id) for thread_id, _revision in values),
)
+234 -15
View File
@@ -3,6 +3,7 @@
import json
import sqlite3
import time
from datetime import date
from pathlib import Path
@@ -19,6 +20,7 @@ class TodayStore:
timeout: float = 1.0,
operation_limit: int = 4096,
operation_retention_seconds: float = 30 * 24 * 60 * 60,
recap_limit: int = 100,
clock=time.time,
):
self.path = Path(path)
@@ -26,6 +28,7 @@ class TodayStore:
self.timeout = timeout
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.recap_limit = recap_limit
self.clock = clock
def _connect(self) -> sqlite3.Connection:
@@ -39,7 +42,9 @@ class TodayStore:
revision INTEGER NOT NULL,
ids TEXT NOT NULL,
capacity_minutes INTEGER,
estimates TEXT NOT NULL DEFAULT '{}'
estimates TEXT NOT NULL DEFAULT '{}',
plan_date TEXT,
timezone TEXT
)
"""
)
@@ -48,6 +53,10 @@ class TodayStore:
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
if "estimates" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
if "plan_date" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
if "timezone" not in plan_columns:
connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_operations (
@@ -65,6 +74,33 @@ class TodayStore:
"UPDATE today_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_recaps (
login TEXT NOT NULL,
session_id TEXT NOT NULL,
created_at REAL NOT NULL,
items TEXT NOT NULL,
PRIMARY KEY (login, session_id)
)
"""
)
connection.execute(
"CREATE INDEX IF NOT EXISTS today_recaps_recent "
"ON today_recaps(login, created_at DESC)"
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS today_time_logs (
login TEXT NOT NULL,
session_id TEXT NOT NULL,
identity TEXT NOT NULL,
actual_minutes INTEGER NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (login, session_id, identity)
)
"""
)
connection.commit()
return connection
@@ -98,21 +134,146 @@ class TodayStore:
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
ids = json.loads(row[1])
estimates = json.loads(row[3] or "{}")
return {
snapshot = {
"revision": int(row[0]),
"ids": ids,
"capacity_minutes": row[2],
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
}
if len(row) > 4 and row[4]:
snapshot["plan_date"] = row[4]
snapshot["timezone"] = row[5]
return snapshot
def get(self, login: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?",
(self._normalize_login(login),),
).fetchone()
return self._snapshot(row)
@staticmethod
def _normalize_recap_items(items: list[dict]) -> list[dict]:
if not isinstance(items, list) or not items:
raise ValueError("recap requires at least one item")
if len(items) > 20:
raise ValueError("recap is limited to 20 items")
normalized = []
seen = set()
for item in items:
identity = item.get("identity", "") if isinstance(item, dict) else ""
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
raise ValueError("recap item identity is required and bounded")
identity = identity.strip()
if identity in seen:
raise ValueError("recap item identities must be unique")
actual = item.get("actual_minutes")
estimate = item.get("estimate_minutes")
if not isinstance(actual, int) or isinstance(actual, bool) or actual < 0 or actual > 1440:
raise ValueError("actual minutes must be between 0 and 1440")
if estimate is not None and (
not isinstance(estimate, int) or isinstance(estimate, bool)
or estimate < 5 or estimate > 1440
):
raise ValueError("estimate minutes must be between 5 and 1440")
seen.add(identity)
normalized.append({
"identity": identity,
"estimate_minutes": estimate,
"actual_minutes": actual,
})
return normalized
@staticmethod
def _recap_snapshot(session_id: str, created_at: float, serialized: str) -> dict:
items = json.loads(serialized)
estimated = sum(item["estimate_minutes"] for item in items if item["estimate_minutes"] is not None)
actual = sum(item["actual_minutes"] for item in items)
return {
"session_id": session_id,
"created_at": created_at,
"items": items,
"estimated_minutes": estimated,
"actual_minutes": actual,
"variance_minutes": actual - estimated,
}
def save_recap(self, login: str, session_id: str, items: list[dict]) -> dict:
login = self._normalize_login(login)
if not isinstance(session_id, str) or not session_id.strip() or len(session_id) > 100:
raise ValueError("session_id is required and bounded")
session_id = session_id.strip()
normalized = self._normalize_recap_items(items)
serialized = json.dumps(normalized, separators=(",", ":"))
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT created_at, items FROM today_recaps WHERE login = ? AND session_id = ?",
(login, session_id),
).fetchone()
if existing is not None:
return self._recap_snapshot(session_id, existing[0], existing[1])
created_at = self.clock()
connection.execute(
"INSERT INTO today_recaps(login, session_id, created_at, items) VALUES (?, ?, ?, ?)",
(login, session_id, created_at, serialized),
)
connection.execute(
"DELETE FROM today_recaps WHERE login = ? AND rowid NOT IN "
"(SELECT rowid FROM today_recaps WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?)",
(login, login, self.recap_limit),
)
return self._recap_snapshot(session_id, created_at, serialized)
def list_recaps(self, login: str, *, limit: int = 30) -> list[dict]:
bounded_limit = max(1, min(int(limit), self.recap_limit, 100))
with self._connect() as connection:
rows = connection.execute(
"SELECT session_id, created_at, items FROM today_recaps WHERE login = ? "
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
(self._normalize_login(login), bounded_limit),
).fetchall()
return [self._recap_snapshot(*row) for row in rows]
def begin_time_log(self, login: str, session_id: str, identity: str, actual_minutes: int) -> str:
"""Claim one recap item for upstream logging, returning its current state."""
login = self._normalize_login(login)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT actual_minutes, status FROM today_time_logs "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
).fetchone()
if row is None:
connection.execute(
"INSERT INTO today_time_logs(login, session_id, identity, actual_minutes, status) "
"VALUES (?, ?, ?, ?, 'pending')",
(login, session_id, identity, actual_minutes),
)
return "claimed"
if row[0] != actual_minutes:
raise ValueError("logged recap time cannot be changed")
if row[1] == "failed":
connection.execute(
"UPDATE today_time_logs SET status = 'pending' "
"WHERE login = ? AND session_id = ? AND identity = ?",
(login, session_id, identity),
)
return "claimed"
return row[1]
def finish_time_log(self, login: str, session_id: str, identity: str, *, succeeded: bool) -> None:
with self._connect() as connection:
connection.execute(
"UPDATE today_time_logs SET status = ? "
"WHERE login = ? AND session_id = ? AND identity = ? AND status = 'pending'",
("logged" if succeeded else "failed", self._normalize_login(login), session_id, identity),
)
def apply(
self,
login: str,
@@ -133,7 +294,8 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
duplicate = connection.execute(
@@ -172,8 +334,10 @@ class TodayStore:
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
snapshot.get("plan_date"), snapshot.get("timezone")),
)
elif changed:
connection.execute(
@@ -181,12 +345,16 @@ class TodayStore:
(revision, serialized_ids, serialized_estimates, login),
)
self._record_operation(connection, login, operation_id)
return {
result = {
"revision": revision,
"ids": ids,
"capacity_minutes": snapshot["capacity_minutes"],
"estimates": estimates,
}
if snapshot.get("plan_date"):
result["plan_date"] = snapshot["plan_date"]
result["timezone"] = snapshot["timezone"]
return result
def apply_batch(self, login: str, operations: list[dict]) -> dict:
"""Apply an ordered batch with one lock and receipt per operation."""
@@ -194,12 +362,15 @@ class TodayStore:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
row = connection.execute(
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
"FROM today_plans WHERE login = ?", (login,)
).fetchone()
snapshot = self._snapshot(row)
ids = list(snapshot["ids"])
capacity_minutes = snapshot["capacity_minutes"]
estimates = dict(snapshot["estimates"])
plan_date = snapshot.get("plan_date")
timezone = snapshot.get("timezone")
revision = snapshot["revision"]
accepted: list[str] = []
duplicates: list[str] = []
@@ -212,7 +383,7 @@ class TodayStore:
direction = operation.get("direction")
if not operation_id or not item_id:
raise ValueError("operation_id and item_id are required")
if action not in {"add", "remove", "move", "configure"}:
if action not in {"add", "remove", "move", "configure", "rollover"}:
raise ValueError("unsupported Today action")
if action == "move" and direction not in {"up", "down"}:
raise ValueError("move direction must be up or down")
@@ -256,7 +427,7 @@ class TodayStore:
if index >= 0 and 0 <= target < len(ids):
ids[index], ids[target] = ids[target], ids[index]
changed = True
else:
elif action == "configure":
proposed_capacity = operation.get("capacity_minutes")
if proposed_capacity is not None and (
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
@@ -276,6 +447,48 @@ class TodayStore:
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
capacity_minutes = proposed_capacity
estimates = normalized_estimates
else:
proposed_date = operation.get("plan_date")
proposed_timezone = operation.get("timezone")
try:
if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
raise ValueError
except (TypeError, ValueError):
raise ValueError("plan_date must be an ISO calendar date") from None
if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
raise ValueError("timezone is required and bounded")
proposed_ids = operation.get("ids")
if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
not isinstance(candidate, str) or not candidate or len(candidate) > 500
for candidate in proposed_ids
):
raise ValueError("rollover IDs are invalid or exceed the Today limit")
if len(set(proposed_ids)) != len(proposed_ids):
raise ValueError("rollover IDs must be unique")
proposed_capacity = operation.get("capacity_minutes")
if proposed_capacity is not None and (
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
or proposed_capacity < 15 or proposed_capacity > 1440
):
raise ValueError("capacity_minutes must be between 15 and 1440")
proposed_estimates = operation.get("estimates", {})
if not isinstance(proposed_estimates, dict):
raise ValueError("estimates must be an object")
normalized_estimates = {}
for estimate_id, minutes in proposed_estimates.items():
if estimate_id not in proposed_ids:
continue
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
raise ValueError("estimate minutes must be between 5 and 1440")
normalized_estimates[estimate_id] = minutes
changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
estimates != normalized_estimates or plan_date != proposed_date or
timezone != proposed_timezone.strip())
ids = list(proposed_ids)
capacity_minutes = proposed_capacity
estimates = normalized_estimates
plan_date = proposed_date
timezone = proposed_timezone.strip()
revision += 1 if changed else 0
self._record_operation(connection, login, operation_id)
@@ -285,15 +498,17 @@ class TodayStore:
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
if row is None:
connection.execute(
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
(login, revision, serialized, capacity_minutes, serialized_estimates),
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
)
elif accepted:
connection.execute(
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
(revision, serialized, capacity_minutes, serialized_estimates, login),
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
"plan_date = ?, timezone = ? WHERE login = ?",
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
)
return {
result = {
"revision": revision,
"ids": ids,
"capacity_minutes": capacity_minutes,
@@ -302,3 +517,7 @@ class TodayStore:
"duplicate_operation_ids": duplicates,
"rejected_operations": rejected,
}
if plan_date:
result["plan_date"] = plan_date
result["timezone"] = timezone
return result
+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
+488
View File
@@ -0,0 +1,488 @@
import json
import subprocess
from pathlib import Path
BATCH_FIND_WORK = Path(__file__).parents[1] / "frontend" / "batch-find-work.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
HTML = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
WORKER = Path(__file__).parents[1] / "frontend" / "service-worker.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_batch_preflights_today_capacity_before_claiming_any_issue():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>1,
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
onProgress:progress=>calls.push('progress:'+progress.status),
}});
flow.run([
{{repository:'stackchain/dashboard',number:671}},
{{repository:'stackchain/dashboard',number:672}},
]).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "full",
"selected": 2,
"available": 1,
"queued": [],
"failed": [],
},
"calls": ["progress:full"],
}
def test_batch_claims_in_order_continues_after_conflict_and_reports_truthfully():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
claim:item=>{{
calls.push('claim:'+item.number);
return item.number===672 ? Promise.reject(new Error('already claimed')) :
Promise.resolve({{...item,assignees:['timmy']}});
}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
onProgress:progress=>calls.push('progress:'+progress.status+':'+progress.processed),
}});
flow.run([671,672,673].map(number=>({{repository:'stackchain/dashboard',number}})))
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete",
"selected": 3,
"available": 3,
"queued": ["stackchain/dashboard#671", "stackchain/dashboard#673"],
"failed": [{"key": "stackchain/dashboard#672", "reason": "already claimed"}],
},
"calls": [
"claim:671", "queue:671", "progress:running:1",
"claim:672", "progress:running:2",
"claim:673", "queue:673", "progress:running:3",
"progress:complete:3",
],
}
def test_batch_does_not_reclaim_confirmed_issue_when_today_sync_needs_recovery():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>1,
claim:item=>{{calls.push('claim');return Promise.resolve({{...item,assignees:['timmy']}});}},
queue:item=>{{calls.push('queue');return 'sync-unavailable';}},
}});
flow.run([{{repository:'stackchain/dashboard',number:671}}])
.then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete",
"selected": 1,
"available": 1,
"queued": [],
"failed": [{
"key": "stackchain/dashboard#671",
"reason": "assigned but Today sync is unavailable",
"assigned": True,
}],
},
"calls": ["claim", "queue"],
}
def test_interrupted_batch_resumes_confirmed_assignment_without_claiming_twice():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const values={{}};
const storage={{
getItem:key=>values[key] || null,
setItem:(key,value)=>{{values[key]=value;}},
removeItem:key=>{{delete values[key];}},
}};
const calls=[];
const item={{repository:'stackchain/dashboard',number:679,title:'Recover me'}};
const first=createBatchFindWork({{
capacity:()=>1, storage, owner:()=> 'timmy',
claim:value=>{{calls.push('claim');return Promise.resolve({{...value,assignees:['timmy']}});}},
queue:()=>{{calls.push('queue-failed');return 'sync-unavailable';}},
}});
first.run([item]).then(firstResult=>{{
const second=createBatchFindWork({{
capacity:()=>1, storage, owner:()=> 'timmy',
claim:()=>{{calls.push('duplicate-claim');return Promise.resolve(item);}},
queue:value=>{{calls.push('queue-resumed:'+value.assignees[0]);return 'queued';}},
}});
return second.resume().then(resumed=>process.stdout.write(JSON.stringify({{
firstResult, resumed, calls, keys:Object.keys(values),
}})));
}});
"""
assert run_node(script) == {
"firstResult": {
"status": "complete", "selected": 1, "available": 1, "queued": [],
"failed": [{
"key": "stackchain/dashboard#679",
"reason": "assigned but Today sync is unavailable",
"assigned": True,
}],
},
"resumed": {
"status": "complete", "selected": 1, "available": 1,
"queued": ["stackchain/dashboard#679"], "failed": [],
},
"calls": ["claim", "queue-failed", "queue-resumed:timmy"],
"keys": [],
}
def test_resumed_batch_skips_queued_items_and_continues_in_original_order():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const key='stackchain.find-work-batch.v1.timmy';
const values={{[key]:JSON.stringify({{
owner:'timmy', available:2, items:[
{{item:{{repository:'stackchain/dashboard',number:678}},state:'queued'}},
{{item:{{repository:'stackchain/dashboard',number:679}},state:'pending'}},
],
}})}};
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>2, owner:()=> 'timmy',
storage:{{getItem:key=>values[key]||null,setItem:(key,value)=>values[key]=value,removeItem:key=>delete values[key]}},
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
flow.resume().then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 2, "available": 2,
"queued": ["stackchain/dashboard#678", "stackchain/dashboard#679"],
"failed": [],
},
"calls": ["claim:679", "queue:679"],
}
def test_time_budget_blocks_claims_until_every_estimate_fits():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:90,planned_minutes:30}}),
claim:item=>{{calls.push('claim:'+item.number);return Promise.resolve(item);}},
queue:item=>{{calls.push('queue:'+item.number);return 'queued';}},
}});
const items=[701,702].map(number=>({{repository:'stackchain/dashboard',number}}));
Promise.all([
flow.run(items, {{'stackchain/dashboard#701':30}}),
flow.run(items, {{'stackchain/dashboard#701':30,'stackchain/dashboard#702':45}}),
]).then(results=>process.stdout.write(JSON.stringify({{results,calls}})));
"""
assert run_node(script) == {
"results": [
{
"status": "estimates-required", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 30,
"invalid": ["stackchain/dashboard#702"],
},
{
"status": "over-budget", "selected": 2, "available": 3,
"queued": [], "failed": [], "remaining_minutes": 60,
"requested_minutes": 75, "over_minutes": 15,
},
],
"calls": [],
}
def test_time_budget_persists_estimates_only_for_successfully_queued_claims():
script = f"""
const createBatchFindWork=require({json.dumps(str(BATCH_FIND_WORK))});
const calls=[];
const flow=createBatchFindWork({{
capacity:()=>3,
timeBudget:()=>({{capacity_minutes:120,planned_minutes:30}}),
claim:item=>item.number===702 ? Promise.reject(new Error('already claimed')) : Promise.resolve(item),
queue:item=>'queued',
persistEstimate:(item,minutes)=>calls.push(item.number+':'+minutes),
}});
flow.run([701,702].map(number=>({{repository:'stackchain/dashboard',number}})),{{
'stackchain/dashboard#701':30,
'stackchain/dashboard#702':45,
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
assert run_node(script) == {
"result": {
"status": "complete", "selected": 2, "available": 3,
"queued": ["stackchain/dashboard#701"],
"failed": [{"key": "stackchain/dashboard#702", "reason": "already claimed"}],
},
"calls": ["701:30"],
}
def test_mobile_find_work_has_preclaim_time_review_controls():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-estimate-review"' in html
assert 'id="find-work-estimate-list"' in html
assert 'id="confirm-find-work-estimates"' in html
assert 'inputmode="numeric"' in dashboard
assert "timeBudget:" in dashboard
assert "persistEstimate:" in dashboard
assert ".find-work-estimate-review" in css
assert ".find-work-estimate-review input" in css and "min-height:44px" in css
def test_find_work_selection_survives_loaded_pages_and_removes_confirmed_claims():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pages={{
1:{{items:[{{id:1,repository:'stackchain/dashboard',number:671}}],page:1,total:2,has_more:true}},
2:{{items:[{{id:2,repository:'stackchain/dashboard',number:672}}],page:2,total:2,has_more:false}},
}};
const controller=createFindWork({{
fetchJson:path=>path.includes('/claim') ? Promise.resolve({{repository:'stackchain/dashboard',number:671,assignees:['timmy']}}) :
Promise.resolve(pages[path.includes('page=2') ? 2 : 1]),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
controller.startSelection();
controller.toggleSelection(controller.items()[0]);
return controller.loadMore();
}}).then(()=>{{
controller.toggleSelection(controller.items()[1]);
return controller.claim(controller.items()[0]);
}}).then(()=>process.stdout.write(JSON.stringify({{
selected:controller.selectedItems().map(item=>item.number),
selecting:controller.selection().active,
}})));
"""
assert run_node(script) == {"selected": [672], "selecting": True}
def test_fill_today_selects_ranked_matches_up_to_remaining_capacity():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const states=[];
const controller=createFindWork({{
fetchJson:()=>Promise.resolve({{items:[
{{id:1,repository:'stackchain/dashboard',number:701}},
{{id:2,repository:'stackchain/dashboard',number:702}},
{{id:3,repository:'stackchain/dashboard',number:703}},
{{id:4,repository:'stackchain/dashboard',number:704}},
],page:1,total:4,has_more:false}}),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}},
onSelection:state=>states.push(state),
}});
controller.load().then(()=>{{
controller.startSelection();
controller.toggleSelection(controller.items()[1]);
const outcome=controller.fillSelection(3);
process.stdout.write(JSON.stringify({{
outcome,
selected:controller.selectedItems().map(item=>item.number),
lastState:states.at(-1),
}}));
}});
"""
assert run_node(script) == {
"outcome": {"selected": 3, "added": 2, "limit": 3},
"selected": [702, 701, 703],
"lastState": {
"active": True,
"count": 3,
"ids": [
"stackchain/dashboard#702",
"stackchain/dashboard#701",
"stackchain/dashboard#703",
],
},
}
def test_fill_today_with_no_capacity_does_not_change_selection():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const controller=createFindWork({{
fetchJson:()=>Promise.resolve({{items:[{{id:1,repository:'stackchain/dashboard',number:701}}]}}),
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
controller.load().then(()=>{{
const outcome=controller.fillSelection(0);
process.stdout.write(JSON.stringify({{outcome,selection:controller.selection()}}));
}});
"""
assert run_node(script) == {
"outcome": {"selected": 0, "added": 0, "limit": 0},
"selection": {"active": False, "count": 0, "ids": []},
}
def test_mobile_find_work_exposes_accessible_batch_controls_and_offline_asset():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
worker = WORKER.read_text()
assert 'id="select-find-work"' in html
assert 'id="batch-find-work-actions"' in html
assert 'id="claim-selected-work"' in html
assert 'aria-live="polite"' in html
assert "createBatchFindWork({" in dashboard
assert "findWorkController.startSelection()" in dashboard
assert "findWorkController.toggleSelection(item)" in dashboard
assert ".find-work-batch-actions" in css
assert "position:sticky" in css
assert "env(safe-area-inset-bottom)" in css
assert "BASE + 'static/batch-find-work.js'" in worker
assert '<script src="static/batch-find-work.js"></script>' in html
def test_mobile_find_work_exposes_touch_accessible_batch_recovery():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="resume-find-work-batch"' in html
assert 'aria-describedby="find-work-status"' in html
assert "mountRecovery(" in BATCH_FIND_WORK.read_text()
assert "typeof localStorage === 'undefined'" in BATCH_FIND_WORK.read_text()
assert ".find-work-batch-recovery" in css and "min-height:44px" in css
def test_mobile_find_work_exposes_fill_today_action_with_capacity_handoff():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="fill-find-work-today"' in html
assert 'aria-describedby="find-work-selection-status"' in html
assert "findWorkController.fillSelection(remainingSlots)" in dashboard
assert "openFindWorkEstimateReview(findWorkController.selectedItems())" in dashboard
assert ".fill-find-work-today" in css and "min-height:44px" in css
def test_find_work_search_ignores_stale_response_and_preserves_cross_query_selection():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const pending=[];
const rendered=[];
const fetchJson=path=>new Promise(resolve=>pending.push({{path,resolve}}));
const controller=createFindWork({{
fetchJson,
onItems:items=>rendered.push(items.map(item=>item.number)),
onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}},
}});
const first=controller.search('api');
const second=controller.search('dashboard');
pending[1].resolve({{items:[{{id:2,repository:'stackchain/dashboard',number:673}}],page:1,total:1,has_more:false}});
second.then(()=>{{
controller.startSelection();
controller.toggleSelection(controller.items()[0]);
const third=controller.search('worker');
pending[2].resolve({{items:[{{id:3,repository:'stackchain/worker',number:674}}],page:1,total:1,has_more:false}});
return third;
}}).then(()=>{{
controller.toggleSelection(controller.items()[0]);
pending[0].resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false}});
return first;
}}).then(()=>process.stdout.write(JSON.stringify({{
calls:pending.map(entry=>entry.path),
rendered,
items:controller.items().map(item=>item.number),
selected:controller.selectedItems().map(item=>item.number),
}})));
"""
assert run_node(script) == {
"calls": [
"api/v1/available-issues?page=1&facets=true&q=api",
"api/v1/available-issues?page=1&facets=true&q=dashboard",
"api/v1/available-issues?page=1&facets=true&q=worker",
],
"rendered": [[673], [674]],
"items": [674],
"selected": [673, 674],
}
def test_mobile_find_work_search_has_clear_live_results_and_touch_targets():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-search"' in html
assert 'id="clear-find-work-search"' in html
assert 'id="find-work-match-status"' in html
assert 'aria-live="polite"' in html
assert "findWorkController.search(" in dashboard
assert "#find-work-search" in dashboard
assert ".find-work-search" in css
assert ".find-work-search button" in css and "min-height:44px" in css
def test_find_work_facets_serialize_filters_and_preserve_selection():
script = f"""
const createFindWork=require({json.dumps(str(PICK_WORK))});
const calls=[];
const controller=createFindWork({{
fetchJson:path=>{{ calls.push(path); return Promise.resolve({{items:[{{id:1,repository:'stackchain/api',number:1}}],page:1,total:1,has_more:false,facets:{{repositories:['stackchain/api'],labels:['critical']}}}}); }},
onItems:()=>{{}}, onPagination:()=>{{}}, onStatus:()=>{{}}, onSelection:()=>{{}}, onFacets:()=>{{}},
}});
controller.reset({{items:[{{id:9,repository:'stackchain/old',number:9}}],page:1,total:1,has_more:false}});
controller.startSelection(); controller.toggleSelection(controller.items()[0]);
controller.setFilters({{repositories:['stackchain/api'],labels:['critical']}}).then(()=>process.stdout.write(JSON.stringify({{
calls, filters:controller.filters(), selected:controller.selectedItems().map(item=>item.number)
}})));
"""
assert run_node(script) == {
"calls": ["api/v1/available-issues?page=1&facets=true&repository=stackchain%2Fapi&label=critical"],
"filters": {"repositories": ["stackchain/api"], "labels": ["critical"]},
"selected": [9],
}
def test_mobile_find_work_exposes_accessible_touch_sized_facets():
html = HTML.read_text()
dashboard = DASHBOARD.read_text()
css = CSS.read_text()
assert 'id="find-work-filters"' in html
assert 'id="find-work-repository-filters"' in html
assert 'id="find-work-label-filters"' in html
assert 'id="clear-find-work-filters"' in html
assert 'aria-label="Filter available work"' in html
assert "setFilters(next)" in PICK_WORK.read_text()
assert ".find-work-filters" in css
assert ".find-work-filter-option" in css and "min-height:44px" in css
+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-v87" in worker
assert "stackchain-dashboard-shell-v98" in worker
+185 -8
View File
@@ -1,10 +1,12 @@
import asyncio
import sqlite3
import threading
import time
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from fastapi import Request
from src import main
from src.session_store import SessionStoreError
@@ -14,6 +16,7 @@ from src.views import FRONTEND_BUILD
@pytest.fixture
def access_control(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
@@ -167,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
@@ -1363,6 +1462,78 @@ async def test_mutation_requires_same_origin_and_session_csrf(access_control, mo
assert calls == 1
@pytest.mark.anyio
async def test_operator_mode_rejects_unconfigured_or_foreign_public_origin_before_auth(
monkeypatch,
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv(
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
"a-separate-session-signing-secret-with-enough-entropy",
)
monkeypatch.delenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
missing = await client.get("/login")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "http://test")
insecure = await client.get("/login")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
foreign_host = await client.get("/login")
assert missing.status_code == 503
assert insecure.status_code == 503
assert foreign_host.status_code == 421
@pytest.mark.anyio
async def test_canonical_origin_drives_csrf_and_passkey_defaults(access_control, monkeypatch):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
monkeypatch.delenv("STACKCHAIN_PASSKEY_RP_ID", raising=False)
monkeypatch.delenv("STACKCHAIN_PASSKEY_ORIGIN", raising=False)
calls = 0
async def mark_read(notification_id):
nonlocal calls
calls += 1
return {"id": notification_id, "read": True}
monkeypatch.setattr(main, "mark_notification_read", mark_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(
transport=transport, base_url="https://dashboard.example"
) as client:
await client.post(
"/api/v1/session", json={"access_token": "correct horse battery staple"}
)
csrf = client.cookies["stackchain_csrf"]
valid = await client.patch(
"/api/v1/notifications/7/read",
headers={
"Origin": "https://dashboard.example",
"X-CSRF-Token": csrf,
},
)
request = Request({
"type": "http",
"method": "GET",
"scheme": "https",
"path": "/",
"query_string": b"",
"headers": [(b"host", b"attacker.invalid")],
"server": ("attacker.invalid", 443),
"client": ("127.0.0.1", 1),
})
assert main._passkey_relying_party(request) == (
"dashboard.example",
"https://dashboard.example",
)
assert valid.status_code == 200
assert calls == 1
@pytest.mark.anyio
async def test_logout_clears_session_and_blocks_private_routes(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -1617,13 +1788,18 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
async def test_failed_sign_in_recording_does_not_block_the_event_loop(
access_control, monkeypatch
):
recording_started = threading.Event()
recording_finished = threading.Event()
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def record_failure(self, source):
recording_started.set()
time.sleep(0.15)
recording_finished.set()
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.11", 1234))
@@ -1631,27 +1807,30 @@ async def test_failed_sign_in_recording_does_not_block_the_event_loop(
sign_in = asyncio.create_task(
client.post("/api/v1/session", json={"access_token": "wrong"})
)
assert await asyncio.to_thread(recording_started.wait, 1)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
assert not recording_finished.is_set()
response = await sign_in
assert response.status_code == 401
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
access_control, monkeypatch
):
clear_started = threading.Event()
clear_finished = threading.Event()
class SlowAttempts:
def retry_after(self, source):
time.sleep(0.02)
return 0
def clear(self, source):
clear_started.set()
time.sleep(0.15)
clear_finished.set()
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.12", 1234))
@@ -1662,14 +1841,12 @@ async def test_successful_sign_in_throttle_clear_does_not_block_the_event_loop(
json={"access_token": "correct horse battery staple"},
)
)
assert await asyncio.to_thread(clear_started.wait, 1)
await asyncio.sleep(0)
started = time.perf_counter()
await asyncio.sleep(0.04)
heartbeat_elapsed = time.perf_counter() - started
assert not clear_finished.is_set()
response = await sign_in
assert response.status_code == 200
assert heartbeat_elapsed < 0.08
@pytest.mark.anyio
+21 -9
View File
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
SESSION_JS = ROOT / "frontend" / "session.js"
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
def run_session_scenario(scenario: str) -> dict:
@@ -347,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"] == [
@@ -372,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"]
@@ -502,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"}]
@@ -524,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"}]
@@ -619,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"
@@ -753,7 +759,7 @@ process.stdout.write(JSON.stringify(state));
def test_security_activity_renders_authentication_alerts_before_journal_events():
source = SESSION_JS.read_text()
source = SECURITY_CENTER_JS.read_text()
alerts = source.index("page.authentication_alerts.forEach")
events = source.index("page.events.forEach")
@@ -765,14 +771,14 @@ def test_security_activity_renders_authentication_alerts_before_journal_events()
def test_security_activity_explains_pending_outcome_confirmation():
source = SESSION_JS.read_text()
source = SECURITY_CENTER_JS.read_text()
assert "Outcome confirmation pending" in source
assert "event.status === 'pending'" in source
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
source = SESSION_JS.read_text()
source = SECURITY_CENTER_JS.read_text()
assert "passkey_enrolled: 'Passkey enrolled'" in source
assert "title.textContent = labels[event.kind]" in source
@@ -780,7 +786,7 @@ def test_security_activity_labels_passkey_enrollment_without_html_rendering():
def test_security_activity_labels_consequential_pull_review_decisions():
source = SESSION_JS.read_text()
source = SECURITY_CENTER_JS.read_text()
assert "pull_review_approved: 'Pull request approved'" in source
assert "pull_review_changes_requested: 'Changes requested'" in source
@@ -815,3 +821,9 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
assert 'id="security-activity-list"' in html
assert '>Load older activity</button>' in html
assert '#sign-out-all { min-height:44px; }' in html
def test_security_activity_names_confirmed_gitea_time_logging():
source = SECURITY_CENTER_JS.read_text()
assert "gitea_time_logged: 'Gitea time logged'" in source
+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
+19
View File
@@ -89,3 +89,22 @@ console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appen
"opened": 1,
"appends": 2,
}
def test_security_center_failure_is_accessible_and_retryable_from_active_devices():
result = run_loader("""
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100});
const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
state.node.onerror(); const first=await failed; const failedStatus=status.textContent;
const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
state.node.onload(); const second=await retry;
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
""")
assert result == {
"first": False,
"second": True,
"failedStatus": "Security center could not load. Tap Active devices to retry.",
"opened": 1,
"appends": 2,
}
+50 -4
View File
@@ -1,6 +1,7 @@
import gzip
import re
import shutil
import subprocess
from pathlib import Path
import httpx
@@ -25,7 +26,8 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
assert first.runtime_gzip_bytes == second.runtime_gzip_bytes
assert gzip.decompress(first.runtime_gzip_bytes) == first.runtime_bytes
assert PAGE_SCRIPT.findall(first.dashboard_html) == []
assert first.dashboard_html.count("<script src=") == 1
assert first.dashboard_html.count("<script src=") == 2
assert f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>' in first.dashboard_html
assert f'<script src="{first.runtime_name}"></script>' in first.dashboard_html
assert first.runtime_name.startswith("runtime-")
assert first.runtime_name.endswith(".js")
@@ -45,7 +47,14 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path):
def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
first = build_frontend(FRONTEND)
assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"}
assert set(first.feature_bundles) == {
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
"today-timer", "security-center",
}
assert (
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
f'<script src="{first.runtime_name}"></script>'
) in first.dashboard_html
capture = first.feature_bundles["issue-capture"]
pull_workflow = first.feature_bundles["pull-workflow"]
assert b"function createIssueCapture" not in first.runtime_bytes
@@ -54,11 +63,31 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function createReviewController" not in first.runtime_bytes
assert b"function createPullSheet" in pull_workflow.runtime_bytes
assert b"function createReviewController" in pull_workflow.runtime_bytes
assert len(first.runtime_gzip_bytes) <= 95 * 1024
security_center = first.feature_bundles["security-center"]
assert b"function attachSecurityCenter" not in first.runtime_bytes
assert b"function attachSecurityCenter" in security_center.runtime_bytes
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) <= 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
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
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)
@@ -75,6 +104,23 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert pull_changed.runtime_name == first.runtime_name
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
security_source = changed_frontend / "security-center.js"
security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
security_changed = build_frontend(changed_frontend)
assert security_changed.runtime_name == first.runtime_name
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
build = build_frontend(FRONTEND)
bundles = {"core.js": build.runtime_bytes}
bundles.update({f"{name}.js": bundle.runtime_bytes for name, bundle in build.feature_bundles.items()})
for name, source in bundles.items():
path = tmp_path / name
path.write_bytes(source)
result = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
@pytest.mark.anyio
async def test_feature_chunk_is_immutable_and_rejects_unknown_revision():
@@ -141,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-v87';",
"const CACHE = 'stackchain-dashboard-shell-v98';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+117
View File
@@ -40,6 +40,123 @@ async def test_notification_page_preserves_upstream_total_without_loading_other_
assert [item["id"] for item in result["items"]] == [42]
@pytest.mark.anyio
async def test_unread_notification_snapshot_loads_every_page_with_bounded_concurrency():
active = 0
peak = 0
requested_pages = []
async def upstream(request):
nonlocal active, peak
page = int(request.url.params["page"])
requested_pages.append(page)
active += 1
peak = max(peak, active)
await __import__("asyncio").sleep(0)
active -= 1
start = (page - 1) * 50 + 1
end = min(start + 50, 126)
return httpx.Response(
200,
headers={"X-Total-Count": "125"},
json=[{
"id": thread_id,
"updated_at": f"2026-08-11T12:{thread_id % 60:02d}:00Z",
"subject": {},
"repository": {},
} for thread_id in range(start, end)],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.unread_notification_snapshot(
max_pages=3, max_concurrency=2, deadline_seconds=1
)
finally:
await gitea_proxy.stop_client()
assert requested_pages[0] == 1
assert sorted(requested_pages) == [1, 2, 3]
assert peak == 2
assert result["complete"] is True
assert result["total"] == 125
assert [item["id"] for item in result["items"]] == list(range(1, 126))
@pytest.mark.anyio
async def test_unread_notification_snapshot_rejects_pagination_that_changes_mid_scan():
def upstream(request):
page = int(request.url.params["page"])
total = 51 if page == 1 else 50
start = (page - 1) * 50 + 1
return httpx.Response(
200,
headers={"X-Total-Count": str(total)},
json=[{
"id": thread_id,
"subject": {},
"repository": {},
} for thread_id in range(start, min(start + 50, total + 1))],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(ValueError, match="changed during the scan"):
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_unread_notification_snapshot_rejects_missing_or_duplicate_threads():
def upstream(_request):
return httpx.Response(
200,
headers={"X-Total-Count": "3"},
json=[
{"id": 7, "subject": {}, "repository": {}},
{"id": 7, "subject": {}, "repository": {}},
"malformed",
],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(ValueError, match="incomplete thread set"):
await gitea_proxy.unread_notification_snapshot(deadline_seconds=1)
finally:
await gitea_proxy.stop_client()
@pytest.mark.anyio
async def test_unread_notification_snapshot_fails_before_exceeding_the_scan_limit():
requested_pages = []
def upstream(request):
page = int(request.url.params["page"])
requested_pages.append(page)
start = (page - 1) * 50 + 1
return httpx.Response(
200,
headers={"X-Total-Count": "101"},
json=[
{"id": thread_id, "subject": {}, "repository": {}}
for thread_id in range(start, min(start + 50, 102))
],
)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
with pytest.raises(ValueError, match="scan limit"):
await gitea_proxy.unread_notification_snapshot(
max_pages=2, deadline_seconds=1
)
finally:
await gitea_proxy.stop_client()
assert requested_pages == [1]
@pytest.mark.anyio
async def test_unread_notifications_are_normalized_for_mobile_handoff():
result = gitea_proxy._normalize_notifications(
+20
View File
@@ -7,6 +7,26 @@ from src import gitea_proxy
from src import main
@pytest.mark.anyio
async def test_log_issue_time_uses_canonical_shared_issue_endpoint_and_seconds():
requests = []
async def handler(request):
requests.append(request)
return httpx.Response(201, json={"id": 1})
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
await gitea_proxy.log_issue_time("pull:stackchain/stackchain-dashboard:586:", 2520)
finally:
await gitea_proxy.stop_client()
assert len(requests) == 1
assert requests[0].method == "POST"
assert requests[0].url.path == "/api/v1/repos/stackchain/stackchain-dashboard/issues/586/times"
assert requests[0].read() == b'{"time":2520}'
@pytest.mark.anyio
async def test_gitea_transport_is_reused_across_requests_and_closed():
client_ids = []
+114
View File
@@ -215,6 +215,120 @@ async def test_available_issue_endpoint_is_bounded_retryable_and_no_store(monkey
assert calls == [True]
@pytest.mark.anyio
async def test_available_issue_search_filters_full_catalog_before_pagination(monkeypatch):
async def available():
return [
{
"repository": "stackchain/api",
"number": number,
"title": "Routine API maintenance",
}
for number in range(1, 52)
] + [{
"repository": "stackchain/dashboard",
"number": 673,
"title": "Search the full Find Work catalog",
}]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
by_repo = await client.get("/api/v1/available-issues?page=1&q=DASHBOARD")
by_number = await client.get("/api/v1/available-issues?page=1&q=%23673")
by_title = await client.get("/api/v1/available-issues?page=1&q=full%20find")
expected = [{
"repository": "stackchain/dashboard",
"number": 673,
"title": "Search the full Find Work catalog",
}]
assert by_repo.json() == {"items": expected, "page": 1, "total": 1, "has_more": False}
assert by_number.json() == by_repo.json()
assert by_title.json() == by_repo.json()
@pytest.mark.anyio
async def test_available_issue_facets_filter_before_pagination_and_describe_catalog(monkeypatch):
async def available():
return [
{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]},
{"repository": "stackchain/api", "number": 2, "title": "API docs", "labels": ["docs"]},
{"repository": "stackchain/dashboard", "number": 3, "title": "Critical UI", "labels": ["critical", "frontend"]},
]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("repository", "stackchain/api"), ("label", "critical")],
)
assert response.status_code == 200
assert response.json() == {
"items": [{"repository": "stackchain/api", "number": 1, "title": "Critical API", "labels": ["critical", "backend"]}],
"page": 1,
"total": 1,
"has_more": False,
"facets": {
"repositories": ["stackchain/api", "stackchain/dashboard"],
"labels": ["backend", "critical", "docs", "frontend"],
},
}
@pytest.mark.anyio
async def test_available_issue_facets_or_within_each_facet_and_compose_with_search(monkeypatch):
async def available():
return [
{"repository": "stackchain/api", "number": 1, "title": "Repair worker", "labels": ["backend"]},
{"repository": "stackchain/web", "number": 2, "title": "Repair mobile", "labels": ["frontend"]},
{"repository": "stackchain/docs", "number": 3, "title": "Repair guide", "labels": ["docs"]},
{"repository": "stackchain/api", "number": 4, "title": "Routine task", "labels": ["backend"]},
]
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", available)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("q", "repair"), ("repository", "stackchain/api"), ("repository", "stackchain/web"),
("label", "backend"), ("label", "frontend")],
)
assert [item["number"] for item in response.json()["items"]] == [1, 2]
@pytest.mark.anyio
async def test_available_issue_facets_reject_excess_values_before_scanning(monkeypatch):
async def must_not_scan():
raise AssertionError("invalid facets must be rejected before catalog scan")
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/available-issues",
params=[("label", f"label-{index}") for index in range(11)],
)
assert response.status_code == 422
@pytest.mark.anyio
async def test_available_issue_search_rejects_oversized_query_without_scanning(monkeypatch):
async def must_not_scan():
raise AssertionError("invalid query must be rejected before catalog scan")
monkeypatch.setattr(main.gitea_proxy, "available_issue_snapshot", must_not_scan)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/available-issues", params={"q": "x" * 101})
assert response.status_code == 422
@pytest.mark.anyio
async def test_available_issue_endpoint_reuses_catalog_published_by_another_worker(
monkeypatch, tmp_path
+47
View File
@@ -174,6 +174,53 @@ process.stdout.write(JSON.stringify({firstHidden:firstCard.hidden, remembered:va
assert result == {"firstHidden": True, "remembered": 1, "secondHidden": True}
def test_setup_api_reports_install_state_without_prompting():
result = run_install_scenario(
"""
const windowTarget = new FakeTarget();
const app = createInstallApp({
window:windowTarget, card:{hidden:true}, installButton:new FakeButton(), dismissButton:new FakeButton(),
guidance:{hidden:true}, status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
const before = app.state();
windowTarget.dispatch('beforeinstallprompt', {preventDefault(){}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})});
const after = app.state();
process.stdout.write(JSON.stringify({before, after}));
"""
)
assert result == {
"before": {"state": "unavailable", "detail": "Installation is available from a supported browser."},
"after": {"state": "incomplete", "detail": "Stackchain is ready to install."},
}
def test_captured_install_prompt_remains_available_until_setup_opens():
result = run_install_scenario(
"""
let prevented = 0;
const prompt = {preventDefault(){prevented += 1;}, prompt:async()=>{}, userChoice:Promise.resolve({outcome:'accepted'})};
const card = {hidden:true};
const app = createInstallApp({
window:new FakeTarget(), initialPrompt:prompt, card,
installButton:new FakeButton(), dismissButton:new FakeButton(), guidance:{hidden:true},
status:{textContent:''}, storage:{getItem:()=>null, setItem:()=>{}},
isStandalone:()=>false, isIosSafari:()=>false,
});
app.start();
process.stdout.write(JSON.stringify({state:app.state(), shown:!card.hidden, prevented}));
"""
)
assert result == {
"state": {"state": "incomplete", "detail": "Stackchain is ready to install."},
"shown": True,
"prevented": 1,
}
def test_appinstalled_hides_an_visible_native_promotion():
result = run_install_scenario(
"""
+157
View File
@@ -8,6 +8,163 @@ from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger
@pytest.mark.anyio
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
if request.method == "GET" and request.url.path.endswith("/issues/comments/42"):
return httpx.Response(200, json={
"id": 42,
"body": "Original",
"user": {"login": "timmy"},
"issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/17",
"html_url": f"{gitea_proxy.GITEA_URL}/stackchain/api/issues/17#issuecomment-42",
})
if request.method == "PATCH":
return httpx.Response(200, json={
"id": 42, "body": "Corrected", "user": {"login": "timmy"},
"html_url": "https://gitea.example/stackchain/api/issues/17#issuecomment-42",
})
return httpx.Response(204)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
edited = await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Corrected")
deleted = await gitea_proxy.delete_owned_comment("stackchain/api", 17, 42)
finally:
await gitea_proxy.stop_client()
assert edited["body"] == "Corrected"
assert deleted == {"id": 42, "deleted": True}
assert [(request.method, request.url.path) for request in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
("PATCH", "/api/v1/repos/stackchain/api/issues/comments/42"),
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
("DELETE", "/api/v1/repos/stackchain/api/issues/comments/42"),
]
assert json.loads(requests[2].content) == {"body": "Corrected"}
@pytest.mark.anyio
@pytest.mark.parametrize(
"comment",
[
{"id": 42, "user": {"login": "alex"}, "issue_url": "https://gitea.example/api/v1/repos/stackchain/api/issues/17"},
{"id": 42, "user": {"login": "timmy"}, "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/99"},
{"id": 42, "user": {"login": "timmy"}, "issue_url": "https://evil.example/api/v1/repos/stackchain/api/issues/17"},
],
)
async def test_owned_comment_mutations_reject_other_author_or_thread_without_writing(comment):
requests = []
async def handler(request):
requests.append(request)
if request.url.path == "/api/v1/user":
return httpx.Response(200, json={"login": "timmy"})
return httpx.Response(200, json=comment)
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
with pytest.raises(gitea_proxy.CommentMutationForbiddenError):
await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Nope")
finally:
await gitea_proxy.stop_client()
assert [request.method for request in requests] == ["GET", "GET"]
@pytest.mark.anyio
@pytest.mark.parametrize(
("base_path", "availability_call"),
[
("/api/v1/repos/stackchain/api/issues/17", "issue"),
("/api/v1/repos/stackchain/api/pulls/17", "pull"),
("/api/v1/notifications/91", "notification"),
],
)
async def test_comment_mutation_routes_cover_issue_pull_and_unread_update(
monkeypatch, base_path, availability_call
):
calls = []
async def available_issue(repository, number):
calls.append(("issue", repository, number))
return True
async def available_pull(repository, number):
calls.append(("pull", repository, number))
return True
async def notification_target(thread_id):
calls.append(("notification", thread_id))
return "stackchain/api", 17
async def edit(repository, number, comment_id, body):
calls.append(("edit", repository, number, comment_id, body))
return {"id": comment_id, "author": "timmy", "body": body}
async def delete(repository, number, comment_id):
calls.append(("delete", repository, number, comment_id))
return {"id": comment_id, "deleted": True}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available_issue)
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", available_pull)
monkeypatch.setattr(
main.gitea_proxy, "notification_conversation_target", notification_target,
raising=False,
)
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", edit)
monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
edited = await client.patch(base_path + "/comments/42", json={"body": " Corrected "})
deleted = await client.delete(base_path + "/comments/42")
assert edited.status_code == 200
assert edited.json()["body"] == "Corrected"
assert deleted.status_code == 200
assert deleted.json() == {"id": 42, "deleted": True}
if availability_call == "notification":
assert calls == [
("notification", 91), ("edit", "stackchain/api", 17, 42, "Corrected"),
("notification", 91), ("delete", "stackchain/api", 17, 42),
]
else:
assert calls == [
(availability_call, "stackchain/api", 17),
("edit", "stackchain/api", 17, 42, "Corrected"),
(availability_call, "stackchain/api", 17),
("delete", "stackchain/api", 17, 42),
]
@pytest.mark.anyio
async def test_comment_mutation_route_returns_forbidden_without_hiding_saved_edit(monkeypatch):
async def available(repository, number):
return True
async def forbidden(*args):
raise gitea_proxy.CommentMutationForbiddenError("not owned")
monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available)
monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", forbidden)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/repos/stackchain/api/issues/17/comments/42",
json={"body": "Keep this draft"},
)
assert response.status_code == 403
assert response.json()["detail"] == "You can only change your own comments"
async def repository_access_from(loader, repository):
repositories = await loader()
return next(
+1 -1
View File
@@ -494,7 +494,7 @@ def test_retrying_same_pull_load_preserves_screenshot_but_target_change_clears_i
"\n function closePullSheet", 1
)[0]
assert "if (!sameWorkTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
assert "if (!createPullSheet.sameTarget(selectedPull, item)) pullAttachmentController.clear();" in open_body
def test_pull_screenshot_comment_uploads_or_durably_admits_before_clearing_draft():
+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-v87" in source
assert "stackchain-dashboard-shell-v98" 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-v87" in worker
assert "stackchain-dashboard-shell-v98" 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-v87" in worker
assert "stackchain-dashboard-shell-v98" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+195
View File
@@ -0,0 +1,195 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "mobile-device-setup.js"
def run_scenario(script: str) -> dict:
harness = r"""
const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget {
constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; }
addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; }
}
const state = {installCalls:0, offlineCalls:0, pushCalls:0, focused:null};
const launcher = new FakeTarget();
const closeButton = new FakeTarget();
const sheet = new FakeTarget(); sheet.hidden = true;
const installButton = new FakeTarget();
const offlineButton = new FakeTarget();
const pushButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
const promptCard = new FakeTarget(); promptCard.hidden = true;
const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget();
let now = 1000;
const promptStorage = {
values:{},
getItem(key) { return this.values[key] ?? null; },
setItem(key, value) { this.values[key] = String(value); },
};
let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss,
promptStorage, now:() => now,
getReadiness:() => readiness,
install:async () => { state.installCalls += 1; },
enableOffline:async () => { state.offlineCalls += 1; },
enablePush:async () => { state.pushCalls += 1; },
});
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
completed = subprocess.run(["node", "-e", harness], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_opening_setup_reports_actual_readiness_without_triggering_permissions():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
install:installStatus.textContent,
offline:offlineStatus.textContent,
push:pushStatus.textContent,
summary:readyStatus.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"install": "Stackchain is installed.",
"offline": "Offline work is off.",
"push": "Notifications are unavailable.",
"summary": "1 of 2 available steps ready.",
"calls": [0, 0, 0],
}
def test_incomplete_device_is_discoverable_without_triggering_setup_actions():
result = run_scenario("""
process.stdout.write(JSON.stringify({
hidden:promptCard.hidden,
summary:promptSummary.textContent,
calls:[state.installCalls,state.offlineCalls,state.pushCalls],
}));
""")
assert result == {
"hidden": False,
"summary": "1 of 2 steps complete",
"calls": [0, 0, 0],
}
def test_readiness_card_opens_setup_and_receives_focus_back_on_close():
result = run_scenario("""
await promptLauncher.dispatch('click', {currentTarget:promptLauncher});
const opened = !sheet.hidden && state.focused === closeButton;
await closeButton.dispatch('click');
process.stdout.write(JSON.stringify({
opened,
closed:sheet.hidden,
promptFocused:state.focused === promptLauncher,
}));
""")
assert result == {"opened": True, "closed": True, "promptFocused": True}
def test_not_now_hides_prompt_for_seven_days_then_allows_it_to_return():
result = run_scenario("""
await promptDismiss.dispatch('click');
const hiddenAfterDismiss = promptCard.hidden;
const dismissedUntil = Number(promptStorage.values['stackchain.device-setup-prompt-dismissed-until']);
now = dismissedUntil + 1;
await setup.render();
process.stdout.write(JSON.stringify({hiddenAfterDismiss, delay:dismissedUntil - 1000, hiddenAfterExpiry:promptCard.hidden}));
""")
assert result == {
"hiddenAfterDismiss": True,
"delay": 7 * 24 * 60 * 60 * 1000,
"hiddenAfterExpiry": False,
}
def test_setup_action_rechecks_real_state_before_marking_step_ready():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
readiness.offline = {state:'complete', detail:'Offline work is saved.'};
await offlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:state.offlineCalls,
offline:offlineStatus.textContent,
hidden:offlineButton.hidden,
promptHidden:promptCard.hidden,
summary:readyStatus.textContent,
}));
""")
assert result == {
"calls": 1,
"offline": "Offline work is saved.",
"hidden": True,
"promptHidden": True,
"summary": "This device is ready.",
}
def test_escape_closes_setup_and_restores_launcher_focus():
result = run_scenario("""
await launcher.dispatch('click', {currentTarget:launcher});
await escapeTarget.dispatch('keydown', {key:'Escape'});
process.stdout.write(JSON.stringify({
hidden:sheet.hidden,
launcherFocused:state.focused === launcher,
}));
""")
assert result == {"hidden": True, "launcherFocused": True}
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert 'id="open-device-setup"' in html
assert 'id="device-readiness-card"' in html
assert 'id="finish-device-setup"' in html
assert 'id="dismiss-device-readiness"' in html
assert html.index('id="device-readiness-card"') < html.index('id="my-work-list"')
assert 'id="device-setup-sheet"' in html
assert 'aria-labelledby="device-setup-heading"' in html
assert all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push"))
assert '<script src="static/mobile-device-setup.js"></script>' in html
assert "createMobileDeviceSetup.mount({" in dashboard
assert "promptStorage:localStorage" in dashboard
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
assert "BASE + 'static/mobile-device-setup.js'" in worker
assert "stackchain-dashboard-shell-v98" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
assert "env(safe-area-inset-bottom)" in css
assert ".device-setup-action { min-height:44px;" in css
assert ".device-readiness-actions button { min-height:44px;" 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
+351 -33
View File
@@ -9,20 +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 = [];
@@ -30,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(
@@ -38,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"],
}
@@ -61,12 +66,14 @@ class FakeElement {{
focus() {{ this.focuses += 1; }}
}}
const nav = new FakeElement();
const buttons = Object.fromEntries(['work','find','new','search','drafts'].map(name => [name, new FakeElement()]));
const sessionHud = new FakeElement();
const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()]));
buttons.attention.hidden = true;
const overlay = new FakeElement();
const calls = [];
let observerCallback;
const dock = createDock({{
nav, buttons, overlays:[overlay],
nav, sessionHud, buttons, overlays:[overlay],
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
}});
@@ -75,10 +82,13 @@ buttons.work.click();
buttons.find.click();
overlay.classList.values.add('open'); observerCallback();
const hiddenWhileOpen = nav.hidden;
const hudHiddenWhileOpen = sessionHud.attributes['data-overlay-hidden'];
overlay.classList.values.delete('open'); observerCallback();
buttons.drafts.click();
process.stdout.write(JSON.stringify({{
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden, hudHiddenWhileOpen,
hudHiddenAfterClose:sessionHud.attributes['data-overlay-hidden'] || null,
attentionHidden:buttons.attention.hidden,
findFocuses:buttons.find.focuses,
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
}}));
@@ -92,9 +102,13 @@ process.stdout.write(JSON.stringify({{
"calls": ["work", "find", "drafts"],
"hiddenWhileOpen": True,
"hiddenAfterClose": False,
"hudHiddenWhileOpen": "true",
"hudHiddenAfterClose": None,
"attentionHidden": True,
"findFocuses": 1,
"current": {
"work": None,
"attention": None,
"find": None,
"new": None,
"search": None,
@@ -103,22 +117,57 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_task_dock_shows_contextual_work_label_with_attention_count():
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))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const workLabel = {{ textContent:'' }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel, attentionBadge:badge }});
const dock = createDock({{ nav:{{}}, buttons:{{work}}, workLabel }});
dock.updateWork('continue', 3);
const continuing = {{ text:workLabel.textContent, count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
const continuing = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('resume', 0);
const resuming = {{ text:workLabel.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
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'] }},
}}));
"""
@@ -130,28 +179,89 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"continuing": {
"text": "Continue",
"count": "3",
"hidden": False,
"label": "Continue Today, 3 items need attention",
"label": "Continue Today",
},
"resuming": {"text": "Resume", "hidden": True, "label": "Resume Today"},
"resuming": {"text": "Resume", "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"planning": {"text": "Plan", "label": "Plan Today"},
"fallback": {"text": "Work", "label": "Work"},
}
def test_mobile_task_dock_exposes_and_hides_deduplicated_attention_count():
def test_mobile_today_hud_completes_current_item_and_labels_final_item_for_recap():
script = f"""
const createView = require({json.dumps(str(TIMER))}).createView;
class FakeElement {{
constructor() {{ this.listeners = {{}}; this.hidden = true; this.textContent = ''; this.attributes = {{}}; }}
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
click() {{ return this.listeners.click?.(); }}
setAttribute(name, value) {{ this.attributes[name] = value; }}
}}
const hud = new FakeElement();
const open = new FakeElement();
const toggle = new FakeElement();
const complete = new FakeElement();
const progress = new FakeElement();
const elements = {{
'[data-mobile-today-hud]':[hud],
'[data-mobile-today-open]':[open],
'[data-mobile-today-toggle]':[toggle],
'[data-mobile-today-complete]':[complete],
'[data-work-session-progress]':[progress],
'[data-work-session-adjust-plan]':[],
'[data-work-session-timer-toggle]':[toggle],
}};
let active = true;
const snapshot = {{identity:'issue:r:1:', elapsed_ms:0, running:true}};
const completed = [];
const view = createView({{
timer:{{snapshot:()=>snapshot,totalElapsed:()=>0,pause:()=>true,resume:()=>true}},
isActive:()=>active,
queryAll:selector=>elements[selector] || [],
formatEstimate:value=>value + 'm',
getItem:()=>({{title:'Ship mobile flow'}}),
onComplete:identity=>{{ completed.push(identity); return false; }},
}});
view.update({{index:1,total:2}}, null);
const next = {{hidden:complete.hidden,label:complete.textContent,aria:complete.attributes['aria-label']}};
complete.click();
const preservedAfterFailure = {{hudHidden:hud.hidden,identity:snapshot.identity}};
view.update({{index:2,total:2}}, null);
const recap = {{hidden:complete.hidden,label:complete.textContent}};
active = false; view.render();
process.stdout.write(JSON.stringify({{next,recap,completed,preservedAfterFailure,hiddenWhenInactive:complete.hidden}}));
"""
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"next": {
"hidden": False,
"label": "Done & next",
"aria": "Complete Ship mobile flow and open next Today item",
},
"recap": {"hidden": False, "label": "Done & recap"},
"completed": ["issue:r:1:"],
"preservedAfterFailure": {"hudHidden": False, "identity": "issue:r:1:"},
"hiddenWhenInactive": True,
}
def test_mobile_task_dock_exposes_independent_attention_action_and_six_column_state():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const nav = {{ attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}, removeAttribute(name) {{ delete this.attributes[name]; }} }};
const attention = {{ attributes: {{}}, hidden:true, setAttribute(name, value) {{ this.attributes[name] = value; }} }};
const badge = {{ textContent:'', hidden:true }};
const dock = createDock({{ nav:{{}}, buttons:{{work}}, attentionBadge:badge }});
const dock = createDock({{ nav, buttons:{{attention}}, attentionBadge:badge }});
dock.updateAttention(3);
const pending = {{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }};
const pending = {{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] }};
dock.updateAttention(0);
process.stdout.write(JSON.stringify({{
pending,
empty:{{ count:badge.textContent, hidden:badge.hidden, label:work.attributes['aria-label'] }},
empty:{{ count:badge.textContent, badgeHidden:badge.hidden, actionHidden:attention.hidden, label:attention.attributes['aria-label'], layout:nav.attributes['data-attention'] || null }},
}}));
"""
result = subprocess.run(
@@ -160,11 +270,175 @@ process.stdout.write(JSON.stringify({{
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"pending": {"count": "3", "hidden": False, "label": "Work, 3 items need attention"},
"empty": {"count": "0", "hidden": True, "label": "Work"},
"pending": {"count": "3", "badgeHidden": False, "actionHidden": False, "label": "Attention, 3 items", "layout": "true"},
"empty": {"count": "0", "badgeHidden": True, "actionHidden": True, "label": "Attention, 0 items", "layout": None},
}
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.",
],
}
def test_mobile_queue_launcher_recommends_and_revalidates_cross_queue_continuation():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{attention: 2, today: 1, later: 4, draft: 3}};
const launcher = createLauncher({{
openToday: () => calls.push('today'),
selectFilter: name => calls.push('filter:' + name),
firstAction: name => (counts[name] || 0) ? {{click() {{ calls.push('open:' + name); }}}} : null,
announce: message => calls.push('announce:' + message),
getCounts: () => counts,
openFindWork: () => calls.push('find'),
}});
const first = launcher.recommend();
counts = {{attention: 0, today: 0, later: 4, draft: 3}};
const opened = launcher.continueWork();
counts = {{attention: 0, today: 0, later: 0, draft: 0}};
const fallback = launcher.recommend();
launcher.continueWork();
process.stdout.write(JSON.stringify({{first, opened, fallback, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"first": {"name": "attention", "count": 2, "label": "Start Attention (2)"},
"opened": "opened",
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["filter:later", "open:later", "find"],
}
@pytest.mark.anyio
async def test_dashboard_renders_accessible_mobile_queue_completion_handoff():
html = await dashboard()
assert 'id="mobile-queue-heading"' in html
assert 'data-mobile-queue="find"' in html
assert "mobileQueueLauncher.recommend()" in html
assert "showMobileQueueCompletion('Updates')" in html
assert "row.setAttribute('data-recommended', 'true')" in html
assert "row.focus()" in html
@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 "name === 'find' ? qs('#find-work').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()
@@ -175,11 +449,14 @@ 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(), countMyWork(activeMyWork).attention)" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
assert "workSession.reopen(item)" in html
assert "runTodayTransition('continue')" in html
@@ -190,11 +467,10 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
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=') == 5
for task in ("work", "find", "new", "search", "drafts"):
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 'padding-bottom:env(safe-area-inset-bottom)' in html
@@ -202,11 +478,53 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "const filter = counts.attention ? 'attention' : 'all';" in html
assert "qs('[data-work-filter=\"' + filter + '\"]').click()" in html
assert "name === 'find' ? qs('#find-work').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 "mobileTaskDock.updateWork(mobileWorkEntry.mode(), countMyWork(activeMyWork).attention)" 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
@pytest.mark.anyio
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
html = await dashboard()
assert 'class="mobile-today-hud" data-mobile-today-hud' in html
assert 'data-mobile-today-open' in html
assert 'data-work-session-progress' in html
assert 'data-work-session-timer-toggle' in html
assert 'data-work-session-adjust-plan' in html
assert 'data-mobile-today-complete' in html
assert 'Done &amp; next' in html
assert "getItem: identity => [...todayMyWork, ...activeMyWork].find" in html
assert "onReopen: identity =>" in html
assert "onComplete: identity =>" in html
assert "completeTodayItem(item)" in html
assert "workSession.reopen(item)" in html
assert '.mobile-today-hud { display:none;' in html
assert 'grid-template-areas:' in html
assert '[data-mobile-today-complete] { grid-area:complete;' in html
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
assert '.mobile-today-hud button { min-height:44px;' in html
assert '.mobile-task-action[hidden] { display:none;' in html
assert 'max-width:100%;' in html
assert 'overflow:hidden;' in html
@pytest.mark.anyio
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
html = await dashboard()
assert 'class="attention-interruption" id="attention-interruption"' in html
assert 'id="return-to-today" type="button">Return to Today</button>' 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
assert '.attention-interruption:not([hidden]) {' in html
assert '.attention-interruption button { min-height:44px;' in html
assert 'max-width:100%;' in html
+1196 -5
View File
File diff suppressed because it is too large. Load diff
+70
View File
@@ -0,0 +1,70 @@
import httpx
import pytest
from src import main
@pytest.mark.anyio
async def test_push_tomorrow_resolves_unread_update_and_persists_canonical_later_item(
monkeypatch,
):
resolved = []
applied = []
async def confirmed_login():
return "timmy"
async def resolve(kind, repository, number, notification_id):
resolved.append((kind, repository, number, notification_id))
return {
"kind": "update",
"notification_id": 42,
"repository": "stackchain/api",
"title": "Retry failed deploy",
}
class Store:
def apply(
self,
login,
operation_id,
action,
item_id,
*,
wake_at=None,
base_revision=None,
):
applied.append(
(login, operation_id, action, item_id, wake_at, base_revision)
)
return {"revision": 3, "records": {item_id: wake_at}}
monkeypatch.setattr(main, "_confirmed_login", confirmed_login)
monkeypatch.setattr(main.gitea_proxy, "resolve_work_route", resolve)
monkeypatch.setattr(main, "_later_store", lambda: Store())
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/later",
json={"wake_at": "2026-08-12T09:00:00.000Z"},
)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"id": 42,
"status": "deferred",
"item_id": "update:stackchain/api::42",
"wake_at": "2026-08-12T09:00:00.000Z",
"revision": 3,
}
assert resolved == [("update", None, None, 42)]
assert applied == [(
"timmy",
"push-tomorrow:42:2026-08-12T09:00:00.000Z",
"defer",
"update:stackchain/api::42",
"2026-08-12T09:00:00.000Z",
None,
)]
+48 -5
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 = []
@@ -347,14 +390,14 @@ async def test_bulk_mark_read_cancels_unfinished_work_at_batch_deadline(monkeypa
monkeypatch.setattr(main, "_read_notification_ids", set())
monkeypatch.setattr(main, "_live_snapshot_value", None)
transport = httpx.ASGITransport(app=main.app)
started_at = time.monotonic()
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
response = await asyncio.wait_for(
client.patch(
"/api/v1/notifications/read", json={"ids": list(range(1, 11))}
),
timeout=0.5,
)
elapsed = time.monotonic() - started_at
assert elapsed < 0.06
assert response.status_code == 200
assert response.json() == {"marked": [], "failed": list(range(1, 11))}
assert response.headers["retry-after"] == "1"
+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
+121 -2
View File
@@ -127,6 +127,114 @@ process.stdout.write(JSON.stringify({{over, confirmation, stillOpen, result, sav
}]
def test_plan_today_applies_recap_actual_only_to_the_draft_after_explicit_action():
script = f"""
const createPlanToday = require({json.dumps(str(PLAN_TODAY))});
const item = number => ({{kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number}});
const saved = [];
const planner = createPlanToday({{
identity: value => 'issue:stackchain/dashboard:' + value.number + ':',
save: plan => {{ saved.push(plan); return true; }},
}});
const first='issue:stackchain/dashboard:1:';
const second='issue:stackchain/dashboard:2:';
planner.open([item(1), item(2)], [item(1), item(2)], {{
capacity_minutes:120, estimates:{{[first]:30,[second]:45}}
}}, {{[first]:52,[second]:45}});
const before=planner.snapshot();
const ignored=planner.applyRecommendation(second);
const applied=planner.applyRecommendation(first);
const after=planner.snapshot();
const result=planner.commit();
process.stdout.write(JSON.stringify({{before,ignored,applied,after,result,saved}}));
"""
result = run_node(script)
assert result["before"]["estimates"] == {
"issue:stackchain/dashboard:1:": 30,
"issue:stackchain/dashboard:2:": 45,
}
assert result["before"]["recommendations"] == {"issue:stackchain/dashboard:1:": 52}
assert result["ignored"] is False
assert result["applied"] is True
assert result["after"]["estimates"]["issue:stackchain/dashboard:1:"] == 52
assert result["after"]["recommendations"] == {}
assert result["saved"][0]["estimates"] == {
"issue:stackchain/dashboard:1:": 52,
"issue:stackchain/dashboard:2:": 45,
}
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))});
@@ -241,14 +349,24 @@ 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
assert "formatPlanMinutes(state.planned_minutes)" in html
assert "planToday.setCapacity" in html
assert "planToday.setEstimate" in html
assert 'data-plan-recommendation="' in html
assert "planToday.applyRecommendation" in html
assert "Use " in html and " as new estimate" in html
assert "todaySync.enqueueConfiguration" in html
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
assert "todayWork.runway(todayMyWork, state.index - 1)" in (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
assert "timer.totalElapsed()" in (Path(__file__).parents[1] / "frontend" / "today-timer.js").read_text()
assert "resetPlanTodayConfirmation()" in html
assert "result === 'confirm-over-capacity'" in html
assert 'id="save-and-start-today"' in html
@@ -292,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-v87" in source
assert "stackchain-dashboard-shell-v98" 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"}]
+142
View File
@@ -459,3 +459,145 @@ async def test_gitea_merge_rejects_failed_ci_without_upstream_mutation():
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/repos/stackchain/api/commits/abc123/status"),
]
@pytest.mark.anyio
async def test_assigned_pull_ownership_endpoints_list_handoff_and_release(monkeypatch):
calls = []
async def assigned(repository, number):
calls.append(("assigned", repository, number))
return True
async def candidates(repository):
calls.append(("candidates", repository))
return [{"login": "alex", "name": "Alexander"}]
async def handoff(repository, number, recipient):
calls.append(("handoff", repository, number, recipient))
return {"repository": repository, "number": number, "assignees": [recipient], "recipient": recipient}
async def release(repository, number):
calls.append(("release", repository, number))
return {"repository": repository, "number": number, "assignees": []}
monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned)
monkeypatch.setattr(main.gitea_proxy, "pull_handoff_candidates", candidates, raising=False)
monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_pull", handoff, raising=False)
monkeypatch.setattr(main.gitea_proxy, "release_assigned_pull", release, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
listed = await client.get("/api/v1/repos/stackchain/api/pulls/7/handoff-candidates")
transferred = await client.patch(
"/api/v1/repos/stackchain/api/pulls/7/handoff", json={"recipient": "alex"}
)
released = await client.patch("/api/v1/repos/stackchain/api/pulls/7/release")
assert listed.status_code == 200
assert listed.headers["cache-control"] == "no-store"
assert listed.json() == [{"login": "alex", "name": "Alexander"}]
assert transferred.status_code == 200
assert transferred.json()["recipient"] == "alex"
assert released.status_code == 200
assert released.json()["assignees"] == []
assert calls == [
("assigned", "stackchain/api", 7),
("candidates", "stackchain/api"),
("handoff", "stackchain/api", 7, "alex"),
("release", "stackchain/api", 7),
]
@pytest.mark.anyio
async def test_gitea_pull_handoff_preserves_coassignees_and_confirms_ownership_exit():
requests = []
async def handler(request):
requests.append((request.method, request.url.path, request.content))
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"assignees": [{"login": "timmy"}, {"login": "sam"}],
})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=[
{"login": "timmy"}, {"login": "sam"},
{"login": "alex", "full_name": "Alexander"},
])
if request.method == "PATCH" and request.url.path.endswith("/issues/7"):
assert request.read()
return httpx.Response(200, json={
"number": 7, "state": "open",
"assignees": [{"login": "sam"}, {"login": "alex"}],
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
result = await gitea_proxy.handoff_assigned_pull("stackchain/api", 7, "alex")
finally:
await gitea_proxy.stop_client()
assert result == {
"repository": "stackchain/api", "number": 7, "state": "open",
"assignees": ["sam", "alex"], "recipient": "alex",
}
assert [(method, path) for method, path, _body in requests] == [
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/pulls/7"),
("GET", "/api/v1/user"),
("GET", "/api/v1/repos/stackchain/api/assignees"),
("PATCH", "/api/v1/repos/stackchain/api/issues/7"),
]
@pytest.mark.anyio
async def test_pull_handoff_candidates_are_bounded_and_exclude_invalid_or_current_logins():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/assignees"):
return httpx.Response(200, json=(
[{"login": "timmy"}, {"login": "bad login"}, {"login": ""}]
+ [{"login": f"user-{index:02d}"} for index in range(30)]
))
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
candidates = await gitea_proxy.pull_handoff_candidates("stackchain/api")
finally:
await gitea_proxy.stop_client()
assert len(candidates) == 25
assert [candidate["login"] for candidate in candidates] == [
f"user-{index:02d}" for index in range(25)
]
@pytest.mark.anyio
async def test_gitea_pull_release_removes_current_login_case_insensitively():
async def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.url.path.endswith("/pulls/7"):
return httpx.Response(200, json={
"number": 7, "state": "open", "merged": False,
"assignees": [{"login": "Timmy"}, {"login": "sam"}],
})
if request.method == "PATCH" and request.url.path.endswith("/issues/7"):
assert request.content == b'{"assignees":["sam"]}'
return httpx.Response(200, json={
"number": 7, "state": "open", "assignees": [{"login": "sam"}],
})
raise AssertionError(f"unexpected request: {request.method} {request.url.path}")
gitea_proxy.start_client(transport=httpx.MockTransport(handler))
try:
released = await gitea_proxy.release_assigned_pull("stackchain/api", 7)
finally:
await gitea_proxy.stop_client()
assert released["assignees"] == ["sam"]
+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)
+602 -14
View File
@@ -1,13 +1,25 @@
import json
import asyncio
import os
import sqlite3
import time
from types import SimpleNamespace
import httpx
import pytest
from src import dashboard_auth, main
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):
@@ -55,6 +67,126 @@ def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path
assert second == []
def test_subscription_store_reopens_a_delivered_thread_when_its_revision_changes(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
first = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
store.mark_delivered("session-a", first[0].thread_revisions)
unchanged = store.claim_unseen({42: "2026-08-11T12:00:00Z"})
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
assert unchanged == []
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
def test_subscription_store_does_not_realert_for_an_older_snapshot_revision(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.mark_delivered("session-a", {42: "2026-08-11T12:05:00Z"})
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
def test_complete_unread_snapshot_reconciles_delivery_and_digest_checkpoints(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
revisions = {1: "r1", 2: "r2", 3: "r3"}
store.mark_delivered("session-a", revisions)
store.mark_digest_pending("session-a", {3: "r3"})
store.reconcile_unread({2})
delivery = store.claim_unseen(revisions)[0]
assert delivery.thread_ids == (1, 3)
assert delivery.digest_ids == ()
@pytest.mark.anyio
async def test_dispatch_reconciles_stale_checkpoints_after_a_complete_snapshot(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.mark_delivered("session-a", {1: "r1"})
async def unread():
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": True}
async def send(_subscription, _payload):
return None
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 1
remaining = store.claim_unseen({1: "r1", 2: "r2"})
assert remaining[0].thread_ids == (1,)
@pytest.mark.anyio
async def test_dispatch_sends_nothing_and_preserves_checkpoints_for_an_incomplete_snapshot(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.mark_delivered("session-a", {1: "r1"})
sent = []
async def unread():
return {"items": [{"id": 2, "updated_at": "r2"}], "complete": False}
async def send(_subscription, payload):
sent.append(payload)
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 0
assert sent == []
assert store.claim_unseen({1: "r1"}) == []
def test_subscription_store_migrates_legacy_delivery_without_replaying_it(tmp_path):
path = tmp_path / "push.sqlite3"
subscription = {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
}
with sqlite3.connect(path) as connection:
connection.executescript("""
CREATE TABLE push_subscriptions (
session_id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
subscription_json TEXT NOT NULL
);
CREATE TABLE push_deliveries (
session_id TEXT NOT NULL,
thread_id INTEGER NOT NULL,
PRIMARY KEY (session_id, thread_id)
);
""")
connection.execute(
"INSERT INTO push_subscriptions VALUES (?, ?, ?)",
("session-a", subscription["endpoint"], json.dumps(subscription)),
)
connection.execute("INSERT INTO push_deliveries VALUES (?, ?)", ("session-a", 42))
store = PushSubscriptionStore(path)
assert store.claim_unseen({42: "2026-08-11T12:00:00Z"}) == []
updated = store.claim_unseen({42: "2026-08-11T12:05:00Z"})
assert updated[0].thread_revisions == ((42, "2026-08-11T12:05:00Z"),)
@pytest.mark.anyio
async def test_managed_session_active_applies_configured_idle_deadline(monkeypatch):
calls = []
@@ -79,17 +211,23 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
async def no_wait(_seconds):
return None
async def stop_after_capture(*_args, **kwargs):
async def stop_after_capture(*args, **kwargs):
captured["unread"] = args[2]
captured.update(kwargs)
raise asyncio.CancelledError
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_CONCURRENCY", "3")
monkeypatch.setenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "4")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert captured["unread"] is main.gitea_proxy.unread_notification_snapshot
assert captured["session_active"] is dashboard_auth.managed_session_active
assert captured["max_concurrency"] == 3
assert captured["max_individual_notifications"] == 4
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
@@ -125,7 +263,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
async def unread():
return {"items": [
{"notification_id": 42, "repository": "private/repo", "title": "Secret title"},
{"id": 42, "repository": "private/repo", "title": "Secret title"},
]}
async def send(subscription, payload):
@@ -139,11 +277,191 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
"body": "Tap to review it in Stackchain.",
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@pytest.mark.anyio
async def test_dispatch_realerts_when_the_same_thread_has_a_newer_revision(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
revision = "2026-08-11T12:00:00Z"
sent = []
async def unread():
return {"items": [{
"id": 42,
"updated_at": revision,
"repository": "private/repo",
"title": "Secret follow-up",
}]}
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 1
assert await dispatch_unread_updates(store, config, unread, send) == 0
revision = "2026-08-11T12:05:00Z"
assert await dispatch_unread_updates(store, config, unread, send) == 1
assert [payload["notification_id"] for payload in sent] == [42, 42]
assert "updated_at" not in json.dumps(sent)
assert "Secret follow-up" not in json.dumps(sent)
@pytest.mark.anyio
async def test_update_burst_sends_bounded_individual_pushes_and_one_private_digest(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
sent = []
async def unread():
return {"items": [
{
"id": notification_id,
"repository": "private/repo",
"title": f"Secret update {notification_id}",
}
for notification_id in range(41, 46)
]}
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
delivered = await dispatch_unread_updates(
store,
config,
unread,
send,
max_individual_notifications=2,
)
assert delivered == 3
assert [payload["tag"] for payload in sent] == [
"stackchain-update-41",
"stackchain-update-42",
"stackchain-update-digest",
]
assert sent[-1] == {
"title": "3 new work updates",
"body": "Tap to review them in Stackchain.",
"route": "#/my-work/updates",
"tag": "stackchain-update-digest",
"update_count": 3,
}
assert "private/repo" not in json.dumps(sent)
assert "Secret update" not in json.dumps(sent)
assert await dispatch_unread_updates(
store,
config,
unread,
send,
max_individual_notifications=2,
) == 0
@pytest.mark.anyio
async def test_failed_digest_retries_only_overflow_after_individual_checkpoints(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
attempts = []
async def unread():
return {"items": [{"id": notification_id} for notification_id in range(51, 56)]}
async def fail_digest(_subscription, payload):
decoded = json.loads(payload)
attempts.append(decoded["tag"])
if decoded["tag"] == "stackchain-update-digest":
raise RuntimeError("push provider unavailable")
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(
store,
config,
unread,
fail_digest,
max_individual_notifications=2,
) == 2
assert store.claim_unseen(range(51, 56))[0].thread_ids == (53, 54, 55)
async def succeed(_subscription, payload):
attempts.append(json.loads(payload)["tag"])
assert await dispatch_unread_updates(
store,
config,
unread,
succeed,
max_individual_notifications=2,
) == 1
assert attempts == [
"stackchain-update-51",
"stackchain-update-52",
"stackchain-update-digest",
"stackchain-update-digest",
]
assert store.claim_unseen(range(51, 56)) == []
@pytest.mark.anyio
async def test_production_notification_page_dispatches_one_unread_push(tmp_path):
def upstream(_request):
return httpx.Response(200, json=[{
"id": 42,
"unread": True,
"repository": {"full_name": "private/repo"},
"subject": {
"title": "Secret title",
"type": "Issue",
"html_url": "https://forge.example/private/repo/issues/7",
},
}])
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
page = await gitea_proxy.notification_page(1)
finally:
await gitea_proxy.stop_client()
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
sent = []
async def unread():
return page
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 1
assert sent == [{
"title": "New work update",
"body": "Tap to review it in Stackchain.",
"route": "#/my-work/update/42",
"tag": "stackchain-update-42",
"notification_id": 42,
}]
assert "private/repo" not in json.dumps(sent)
assert "Secret title" not in json.dumps(sent)
@pytest.mark.anyio
async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
path = tmp_path / "push.sqlite3"
@@ -158,7 +476,7 @@ async def test_concurrent_workers_do_not_dispatch_the_same_update(tmp_path):
sent = []
async def unread():
return {"items": [{"notification_id": 42}]}
return {"items": [{"id": 42}]}
async def send(_subscription, payload):
sent.append(json.loads(payload)["tag"])
@@ -190,7 +508,7 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
response = SimpleNamespace(status_code=410)
async def unread():
return {"items": [{"notification_id": 8}]}
return {"items": [{"id": 8}]}
async def send(_subscription, _payload):
raise Expired()
@@ -200,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")
@@ -211,7 +555,7 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
sent = []
async def unread():
return {"items": [{"notification_id": 8}]}
return {"items": [{"id": 8}]}
async def send(subscription, _payload):
if subscription["endpoint"].endswith("session-a"):
@@ -224,6 +568,107 @@ 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")
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"},
})
attempts = []
async def unread():
return {"items": [
{"id": 8},
{"id": 9},
]}
async def send(subscription, payload):
endpoint = subscription["endpoint"]
thread_id = int(json.loads(payload)["route"].rsplit("/", 1)[1])
attempts.append((endpoint, thread_id))
if endpoint.endswith("failing-device"):
raise RuntimeError("provider unavailable")
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(store, config, unread, send) == 2
assert [item for item in attempts if item[0].endswith("failing-device")] == [
("https://push.example/failing-device", 8)
]
remaining = {item.session_id: item.thread_ids for item in store.claim_unseen({8, 9})}
assert remaining == {"failing-device": (8, 9)}
@pytest.mark.anyio
async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -235,7 +680,7 @@ async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
sent = []
async def unread():
return {"items": [{"notification_id": 13}]}
return {"items": [{"id": 13}]}
async def send(subscription, _payload):
if subscription["endpoint"].endswith("session-a"):
@@ -254,6 +699,45 @@ async def test_timed_out_endpoint_does_not_stall_push_fanout(tmp_path):
assert sent == ["https://push.example/session-b"]
@pytest.mark.anyio
async def test_dispatches_devices_concurrently_with_a_strict_bound(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for index in range(4):
store.upsert(f"session-{index}", {
"endpoint": f"https://push.example/session-{index}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
active = 0
peak = 0
bound_reached = asyncio.Event()
release = asyncio.Event()
async def unread():
return {"items": [{"id": 13}]}
async def send(_subscription, _payload):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
bound_reached.set()
await release.wait()
active -= 1
config = PushConfiguration("public", "private", "mailto:ops@example.com")
dispatch = asyncio.create_task(dispatch_unread_updates(
store, config, unread, send, max_concurrency=2
))
await asyncio.wait_for(bound_reached.wait(), timeout=0.2)
assert peak == 2
assert dispatch.done() is False
release.set()
assert await dispatch == 4
assert peak == 2
@pytest.mark.anyio
async def test_dispatch_removes_inactive_sessions_without_blocking_active_devices(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -265,7 +749,7 @@ async def test_dispatch_removes_inactive_sessions_without_blocking_active_device
sent = []
async def unread():
return {"items": [{"notification_id": 21}]}
return {"items": [{"id": 21}]}
async def session_active(management_id):
return management_id == "active-device"
@@ -292,7 +776,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
sent = []
async def unread():
return {"items": [{"notification_id": 22}]}
return {"items": [{"id": 22}]}
async def registry_unavailable(_management_id):
raise RuntimeError("session registry unavailable")
@@ -322,10 +806,10 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
async def no_unread():
return {"items": []}
async def no_unread(**_kwargs):
return {"items": [], "complete": True}
monkeypatch.setattr(main, "notifications", no_unread)
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", no_unread)
request = SimpleNamespace(state=SimpleNamespace(
dashboard_session=SimpleNamespace(session_id="session-a")
))
@@ -343,6 +827,110 @@ 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")
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 "device-a"
revisions = {
thread_id: f"2026-08-11T12:{thread_id % 60:02d}:00Z"
for thread_id in range(1, 126)
}
async def complete_snapshot(**_kwargs):
return {
"items": [
{"id": thread_id, "updated_at": revision}
for thread_id, revision in revisions.items()
],
"total": 125,
"complete": True,
}
async def first_page_must_not_be_used():
raise AssertionError("subscription used the first-page inbox endpoint")
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", complete_snapshot)
monkeypatch.setattr(main, "notifications", first_page_must_not_be_used)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.PushSubscriptionPayload(
endpoint="https://push.example/device-a",
keys={"p256dh": "public-key", "auth": "auth-secret"},
)
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert store.claim_unseen(revisions) == []
@pytest.mark.anyio
async def test_subscription_baselines_production_notification_ids(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 "device-a"
async def current_unread(**_kwargs):
return {"items": [{"id": 42}], "complete": True}
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", current_unread)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.PushSubscriptionPayload(
endpoint="https://push.example/device-a",
keys={"p256dh": "public-key", "auth": "auth-secret"},
)
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert store.claim_unseen({42}) == []
assert store.claim_unseen({42, 43})[0].thread_ids == (43,)
@pytest.mark.anyio
async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -355,11 +943,11 @@ async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavai
async def management_id(_session):
return "device-a"
async def unavailable():
async def unavailable(**_kwargs):
raise RuntimeError("Gitea unavailable")
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main, "notifications", unavailable)
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", unavailable)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.PushSubscriptionPayload(
endpoint="https://push.example/device-a",
+88
View File
@@ -0,0 +1,88 @@
import json
import subprocess
from pathlib import Path
SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js"
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const calls=[];
const element=()=>({{
hidden:true, disabled:false, textContent:'', children:[],
addEventListener(){{}}, focus(){{this.focused=true;}},
replaceChildren(){{this.children=[];}}, append(value){{this.children.push(value);}},
}});
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()=>{{calls.push('devices');return[];}},
listPasskeys:async()=>{{calls.push('passkeys');return[];}},
listSecurityEvents:async()=>{{calls.push('activity');return{{events:[],authentication_alerts:[],next_cursor:null}};}},
}};
(async()=>{{
const controller=attachSecurityCenter({{root,boundary}});
const loading=controller.open();
const awaitable=Boolean(loading&&typeof loading.then==='function');
if (loading) await loading;
console.log(JSON.stringify({{calls,awaitable,hidden:ids['active-devices-sheet'].hidden,focused:ids['close-active-devices'].focused||false}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
assert json.loads(completed.stdout) == {
"calls": ["devices", "passkeys", "activity"],
"awaitable": True,
"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
+12
View File
@@ -30,6 +30,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
@@ -43,6 +44,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
assert [response.status_code for response in responses] == [200, 200, 200, 401]
for response in responses:
assert_browser_security_boundary(response)
assert response.headers["strict-transport-security"] == "max-age=31536000"
@pytest.mark.anyio
@@ -57,3 +59,13 @@ async def test_security_boundary_covers_fail_closed_authentication_response(monk
assert response.status_code == 503
assert_browser_security_boundary(response)
@pytest.mark.anyio
async def test_insecure_local_mode_does_not_emit_hsts():
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 123))
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client:
response = await client.get("/healthz")
assert response.status_code == 200
assert "strict-transport-security" not in response.headers
+294 -16
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();
@@ -41,6 +45,7 @@ const context = {{
self: {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -57,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'],
@@ -82,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;
@@ -104,10 +115,11 @@ async function dispatchMessage(data, ports = []) {{
listeners.message({{ data, ports, waitUntil: promise => {{ pending = promise; }} }});
if (pending) await pending;
}}
async function dispatchNotificationClick(route) {{
async function dispatchNotificationClick(route, action = '', notificationId = null, tag = null) {{
let pending;
listeners.notificationclick({{
notification: {{data: {{route}}, close: () => {{ state.notificationClosed = true; }}}},
action,
notification: {{tag: tag || ('stackchain-update-' + notificationId), data: {{route, notificationId}}, close: () => {{ state.notificationClosed = true; }}}},
waitUntil: promise => {{ pending = promise; }},
}});
if (pending) await pending;
@@ -133,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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -142,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-v87" in source
assert "stackchain-dashboard-shell-v98" 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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -157,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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -165,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-v87" in source
assert "stackchain-dashboard-shell-v98" 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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -181,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-v87" in source
assert "stackchain-dashboard-shell-v98" 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-v87" in source
assert "stackchain-dashboard-shell-v98" 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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -242,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"}
@@ -329,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}]
@@ -386,7 +404,7 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
"""
await dispatchPush({
title:'New work update', body:'Tap to review it in Stackchain.',
tag:'stackchain-update-42', route:'#/my-work/update/42',
tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42,
repository:'must-not-render',
});
await dispatchNotificationClick('#/my-work/update/42');
@@ -399,7 +417,14 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
"options": {
"body": "Tap to review it in Stackchain.",
"tag": "stackchain-update-42",
"data": {"route": "#/my-work/update/42"},
"actions": [
{"action": "mark-read", "title": "Mark read"},
{"action": "tomorrow", "title": "Tomorrow"},
],
"data": {
"route": "#/my-work/update/42",
"notificationId": 42,
},
},
}]
assert result["opened"] == [
@@ -408,6 +433,203 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
assert "must-not-render" not in json.dumps(result["notifications"])
def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_copy():
result = run_worker_scenario(
"""
await dispatchPush({
title:'must-not-render', body:'private details must-not-render',
tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:7,
});
await dispatchNotificationClick('#/my-work/updates');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [{
"title": "7 new work updates",
"options": {
"body": "Tap to review them in Stackchain.",
"tag": "stackchain-update-digest",
"data": {"route": "#/my-work/updates"},
},
}]
assert result["opened"] == [
"https://forge.example/dashboard/#/my-work/updates"
]
assert "must-not-render" not in json.dumps(result["notifications"])
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
result = run_worker_scenario(
"""
const calls = [];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
const headers = new Headers(options.headers || {});
calls.push({url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token')});
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({ok:true}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == [
{
"url": "https://forge.example/dashboard/api/v1/session",
"method": "GET",
"csrf": None,
},
{
"url": "https://forge.example/dashboard/api/v1/notifications/42/read",
"method": "PATCH",
"csrf": "session-proof",
},
]
assert result["state"]["notificationClosed"] is True
assert result["state"]["opened"] == []
assert result["state"]["focused"] == []
def test_push_tomorrow_action_defers_privately_without_opening_or_marking_read():
result = run_worker_scenario(
"""
const calls = [];
context.self.__STACKCHAIN_NOW = () => new Date('2026-08-11T18:30:00.000Z');
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
const headers = new Headers(options.headers || {});
calls.push({
url, method:String(options.method || 'GET'), csrf:headers.get('X-CSRF-Token'),
body:options.body ? JSON.parse(options.body) : null,
});
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({id:42,status:'deferred'}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'tomorrow', 42, 'stackchain-update-42');
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == [
{
"url": "https://forge.example/dashboard/api/v1/session",
"method": "GET",
"csrf": None,
"body": None,
},
{
"url": "https://forge.example/dashboard/api/v1/notifications/42/later",
"method": "PATCH",
"csrf": "session-proof",
"body": {"wake_at": "2026-08-12T09:00:00.000Z"},
},
]
assert result["state"]["notificationClosed"] is True
assert result["state"]["opened"] == []
assert all(not call["url"].endswith("/read") for call in result["calls"])
def test_push_mark_read_failure_opens_existing_update_reader():
result = run_worker_scenario(
"""
state.clientList = [{
url:'https://forge.example/dashboard/',
navigate:async function(url){ this.url=url; },
focus:async function(){ state.focused.push(this.url); },
}];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Response(JSON.stringify({error:'unavailable'}), {
status:503, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
process.stdout.write(JSON.stringify(state));
"""
)
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/update/42"
]
assert result["opened"] == []
assert result["notificationClosed"] is True
def test_push_mark_read_rejects_mismatched_notification_identity():
result = run_worker_scenario(
"""
const calls = [];
context.fetch = async (request, options = {}) => {
calls.push({url:String(request.url || request), method:String(options.method || 'GET')});
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
};
await dispatchNotificationClick('#/my-work/update/42', 'mark-read', 43);
process.stdout.write(JSON.stringify({state,calls}));
"""
)
assert result["calls"] == []
assert result["state"].get("notificationClosed") is not True
assert result["state"]["opened"] == []
def test_push_mark_read_timeout_aborts_request_and_opens_update_reader():
result = run_worker_scenario(
"""
state.clientList = [{
url:'https://forge.example/dashboard/',
navigate:async function(url){ this.url=url; },
focus:async function(){ state.focused.push(this.url); },
}];
context.fetch = async (request, options = {}) => {
const url = String(request.url || request);
if (url.endsWith('/api/v1/session')) {
return new Response(JSON.stringify({csrf_token:'session-proof'}), {
status:200, headers:{'Content-Type':'application/json'},
});
}
return new Promise((resolve, reject) => {
options.signal?.addEventListener('abort', () => {
state.actionFetchAborted = true;
reject(new Error('aborted'));
}, {once:true});
});
};
dispatchNotificationClick('#/my-work/update/42', 'mark-read', 42);
await new Promise(resolve => setTimeout(resolve, 50));
process.stdout.write(JSON.stringify(state));
process.exit(0);
"""
)
assert result["actionFetchAborted"] is True
assert result["focused"] == [
"https://forge.example/dashboard/#/my-work/update/42"
]
assert result["notificationClosed"] is True
def test_background_mutations_obtain_session_bound_csrf_proof():
source = WORKER.read_text()
@@ -432,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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -455,12 +677,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",
"/dashboard/static/feature-loader.js",
"/dashboard/static/security-center.js",
"/dashboard/static/markdown.js",
"/dashboard/static/commands.js",
"/dashboard/static/search-preview.js",
"/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",
@@ -470,23 +695,32 @@ 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",
"/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js",
"/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",
"/dashboard/static/detail-defer.js",
"/dashboard/static/later-picker.js",
"/dashboard/static/pick-work.js",
"/dashboard/static/batch-find-work.js",
"/dashboard/static/conversation.js",
"/dashboard/static/comment-actions.js",
"/dashboard/static/issue-attachment.js",
"/dashboard/static/issue-sheet.js",
"/dashboard/static/create-issue-sheet.js",
@@ -500,8 +734,10 @@ 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",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
@@ -522,6 +758,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(
"""
@@ -561,6 +836,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-v87';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v98';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+892
View File
@@ -0,0 +1,892 @@
import asyncio
import json
import subprocess
import time
from pathlib import Path
import httpx
import pytest
from src import main
from src.today_store import TodayStore
ROOT = Path(__file__).parents[1]
TODAY_RECAP = ROOT / "frontend" / "today-recap.js"
async def recap_time_grant(client, headers, payload):
challenge = await client.post(
"/api/v1/today/recaps/log-time", json=payload, headers=headers
)
assert challenge.status_code == 428
authorization = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "log_recap_time",
"target": challenge.json()["detail"]["target"],
},
headers=headers,
)
assert authorization.status_code == 201
return authorization.json()["grant"]
def test_mobile_recap_time_logging_controls_are_explicit_accessible_and_opt_in():
html = (ROOT / "frontend" / "index.html").read_text()
source = TODAY_RECAP.read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
assert 'id="save-log-today-recap"' in html
assert "Log selected time to Gitea" in html
assert 'type="checkbox"' in source
assert "data-recap-log-identity" in source
assert "item.actual_minutes > 0" in source
assert "querySelectorAll('[data-recap-log-identity]:checked')" in source
assert ".today-recap-log" in css and "min-height:44px" in css
def test_recap_store_is_idempotent_account_scoped_bounded_and_newest_first(tmp_path):
now = [1_000.0]
store = TodayStore(tmp_path / "today.sqlite3", recap_limit=2, clock=lambda: now[0])
first = {
"session_id": "session-1",
"items": [
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42},
{"identity": "pull:stackchain/api:8:", "estimate_minutes": None, "actual_minutes": 12},
],
}
saved = store.save_recap("Timmy", first["session_id"], first["items"])
replay = store.save_recap("timmy", first["session_id"], first["items"])
now[0] += 1
store.save_recap("timmy", "session-2", [{"identity": "issue:r:2:", "estimate_minutes": 10, "actual_minutes": 8}])
now[0] += 1
store.save_recap("timmy", "session-3", [{"identity": "issue:r:3:", "estimate_minutes": 10, "actual_minutes": 15}])
store.save_recap("alexander", "other", [{"identity": "issue:r:9:", "estimate_minutes": 5, "actual_minutes": 5}])
assert saved == replay
assert saved["session_id"] == "session-1"
assert saved["estimated_minutes"] == 30
assert saved["actual_minutes"] == 54
assert [row["session_id"] for row in store.list_recaps("timmy")] == ["session-3", "session-2"]
assert [row["session_id"] for row in store.list_recaps("alexander")] == ["other"]
@pytest.mark.parametrize(
"items,message",
[
([], "at least one"),
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": -1}], "actual"),
([{"identity": "issue:r:1:", "estimate_minutes": 5, "actual_minutes": 1441}], "actual"),
([{"identity": "", "estimate_minutes": 5, "actual_minutes": 1}], "identity"),
],
)
def test_recap_store_rejects_invalid_or_unbounded_rows(tmp_path, items, message):
store = TodayStore(tmp_path / "today.sqlite3")
with pytest.raises(ValueError, match=message):
store.save_recap("timmy", "session", items)
@pytest.mark.anyio
async def test_authenticated_recap_api_is_no_store_idempotent_and_account_scoped(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
async def user():
return {"id": 1, "login": "Timmy"}
monkeypatch.setattr(main, "current_user", user)
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
payload = {"session_id": "mobile-session-1", "items": [
{"identity": "issue:stackchain/dashboard:579:", "estimate_minutes": 30, "actual_minutes": 42}
]}
created = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
replay = await client.post("/api/v1/today/recaps", json=payload, headers=headers)
history = await client.get("/api/v1/today/recaps")
assert created.status_code == replay.status_code == 200
assert created.json() == replay.json()
assert history.json()["recaps"] == [created.json()]
assert created.headers["cache-control"] == history.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_recap_time_logging_requires_one_exact_single_use_fresh_authorization(
monkeypatch, tmp_path
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
calls = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, seconds):
calls.append((identity, seconds))
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "authorization-session",
"items": [{
"identity": "issue:stackchain/dashboard:589:",
"estimate_minutes": 30,
"actual_minutes": 42,
}],
"log_identities": ["issue:stackchain/dashboard:589:"],
}
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
missing = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
history_before = await client.get("/api/v1/today/recaps")
target = missing.json()["detail"]["target"]
authorization = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "log_recap_time",
"target": target,
},
headers=headers,
)
grant = authorization.json()["grant"]
changed = {**payload, "items": [{**payload["items"][0], "actual_minutes": 43}]}
mismatched = await client.post(
"/api/v1/today/recaps/log-time",
json=changed,
headers={**headers, "X-Step-Up-Grant": grant},
)
accepted = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
replay = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
assert missing.status_code == mismatched.status_code == replay.status_code == 428
assert missing.json()["detail"]["action"] == "log_recap_time"
assert target.startswith("today-recap:") and len(target) == 44
assert history_before.json()["recaps"] == []
assert accepted.status_code == 200
assert calls == [("issue:stackchain/dashboard:589:", 42 * 60)]
@pytest.mark.anyio
async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_outcomes(
monkeypatch, tmp_path
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
security_db = tmp_path / "security.sqlite3"
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(security_db))
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, _seconds):
if ":590:" in identity:
raise httpx.ReadTimeout("response was lost after sending")
if ":591:" in identity:
raise httpx.ConnectError("request was not sent")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "private-recap-session",
"items": [
{"identity": f"issue:stackchain/dashboard:{number}:", "estimate_minutes": 10, "actual_minutes": minutes}
for number, minutes in [(589, 11), (590, 12), (591, 13)]
],
"log_identities": [
f"issue:stackchain/dashboard:{number}:" for number in (589, 590, 591)
],
}
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
challenge = await client.post("/api/v1/today/recaps/log-time", json=payload, headers=headers)
authorization = await client.post(
"/api/v1/fresh-authorization",
json={
"access_token": "correct horse battery staple",
"action": "log_recap_time",
"target": challenge.json()["detail"]["target"],
},
headers=headers,
)
response = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": authorization.json()["grant"]},
)
events = (await client.get("/api/v1/security-events")).json()["events"]
assert response.json()["time_logs"] == [
{"identity": "issue:stackchain/dashboard:589:", "status": "logged"},
{"identity": "issue:stackchain/dashboard:590:", "status": "verify"},
{"identity": "issue:stackchain/dashboard:591:", "status": "retry"},
]
time_events = [event for event in events if event["kind"] == "gitea_time_logged"]
assert {(event["target"], event["status"]) for event in time_events} == {
("stackchain/dashboard#590", "pending"),
("stackchain/dashboard#589", "completed"),
}
persisted = security_db.read_bytes()
assert b"private-recap-session" not in persisted
assert challenge.json()["detail"]["target"].encode() not in persisted
@pytest.mark.anyio
async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurrency(
monkeypatch, tmp_path
):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
active = 0
peak = 0
attempted = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, _seconds):
nonlocal active, peak
active += 1
peak = max(peak, active)
attempted.append(identity)
try:
await asyncio.sleep(0.1)
finally:
active -= 1
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
identities = [f"issue:stackchain/dashboard:{number}:" for number in range(607, 612)]
payload = {
"session_id": "concurrent-recap-session",
"items": [
{"identity": identity, "estimate_minutes": 10, "actual_minutes": 11}
for identity in identities
],
"log_identities": identities,
}
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
grant = await recap_time_grant(client, headers, payload)
started = time.perf_counter()
response = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
elapsed = time.perf_counter() - started
assert response.status_code == 200
assert elapsed < 0.35
assert 1 < peak <= 3
assert set(attempted) == set(identities)
assert response.json()["time_logs"] == [
{"identity": identity, "status": "logged"} for identity in identities
]
@pytest.mark.anyio
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
calls = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, seconds):
calls.append((identity, seconds))
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "mobile-session-1",
"items": [
{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42},
{"identity": "pull:stackchain/dashboard:586:", "estimate_minutes": 10, "actual_minutes": 0},
],
"log_identities": ["issue:stackchain/dashboard:587:"],
}
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
first_grant = await recap_time_grant(client, headers, payload)
created = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": first_grant},
)
replay_grant = await recap_time_grant(client, headers, payload)
replay = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": replay_grant},
)
assert created.status_code == replay.status_code == 200
assert calls == [("issue:stackchain/dashboard:587:", 42 * 60)]
assert created.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert replay.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert created.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_ambiguous_time_log_failure_is_not_reposted_automatically(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
calls = 0
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(_identity, _seconds):
nonlocal calls
calls += 1
raise httpx.ReadTimeout("response was lost after sending")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
payload = {
"session_id": "ambiguous-session",
"items": [{"identity": "issue:stackchain/dashboard:587:", "estimate_minutes": 30, "actual_minutes": 42}],
"log_identities": ["issue:stackchain/dashboard:587:"],
}
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
first_grant = await recap_time_grant(client, headers, payload)
first = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": first_grant},
)
replay_grant = await recap_time_grant(client, headers, payload)
replay = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": replay_grant},
)
assert calls == 1
assert first.json()["time_logs"] == replay.json()["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "verify"
}]
@pytest.mark.anyio
async def test_recap_time_logging_rejects_noncanonical_and_zero_targets_before_upstream(monkeypatch, tmp_path):
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
calls = []
async def user():
return {"id": 1, "login": "Timmy"}
async def log_time(identity, seconds):
calls.append((identity, seconds))
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
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"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
malformed = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
"session_id": "bad-target",
"items": [{"identity": "issue:https://evil.test:1:", "estimate_minutes": 5, "actual_minutes": 5}],
"log_identities": ["issue:https://evil.test:1:"],
})
zero = await client.post("/api/v1/today/recaps/log-time", headers=headers, json={
"session_id": "zero-target",
"items": [{"identity": "issue:stackchain/dashboard:1:", "estimate_minutes": 5, "actual_minutes": 0}],
"log_identities": ["issue:stackchain/dashboard:1:"],
})
history = await client.get("/api/v1/today/recaps")
assert malformed.status_code == zero.status_code == 422
assert calls == []
assert history.json()["recaps"] == []
def test_recap_controller_calculates_variance_validates_corrections_and_clears_after_save():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let cleared=0;
const recap=createRecap({{
save:payload=>{{calls.push(payload); return Promise.resolve(payload);}},
clear:()=>{{cleared += 1;}},
makeId:()=> 'session-fixed',
}});
const draft=recap.begin([
{{identity:'issue:r:1:',elapsed_ms:42*60000}},
{{identity:'pull:r:2:',elapsed_ms:12*60000}},
], {{'issue:r:1:':30}});
const invalidLow=recap.correct('issue:r:1:',-1);
const invalidHigh=recap.correct('issue:r:1:',1441);
const corrected=recap.correct('issue:r:1:',45);
recap.save().then(saved=>process.stdout.write(JSON.stringify({{
draft,invalidLow,invalidHigh,corrected,saved,calls,cleared
}}))).catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["draft"]["estimated_minutes"] == 30
assert output["draft"]["actual_minutes"] == 54
assert output["draft"]["variance_minutes"] == 24
assert output["invalidLow"] is False
assert output["invalidHigh"] is False
assert output["corrected"] is True
assert output["calls"][0]["session_id"] == "session-fixed"
assert output["calls"][0]["items"][0]["actual_minutes"] == 45
assert output["cleared"] == 1
def test_recap_controller_logs_only_selected_nonzero_items_and_retains_failed_draft():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const calls=[]; let fail=true; let cleared=0;
const recap=createRecap({{
save:payload=>Promise.resolve(payload),
saveAndLog:payload=>{{calls.push(payload); return fail ? Promise.reject(new Error('offline')) : Promise.resolve({{
time_logs:payload.log_identities.map(identity=>({{identity,status:'logged'}}))
}});}},
clear:()=>{{cleared += 1;}}, makeId:()=> 'log-session',
}});
recap.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:42*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:0}},
]);
(async()=>{{
let error='';
try {{ await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }}
catch (caught) {{ error=caught.message; }}
const retained=recap.snapshot(); fail=false;
const result=await recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']);
process.stdout.write(JSON.stringify({{error,retained,result,calls,cleared,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["error"] == "offline"
assert output["retained"]["session_id"] == "log-session"
assert output["calls"][0]["log_identities"] == ["issue:stackchain/dashboard:587:"]
assert output["result"]["time_logs"] == [{
"identity": "issue:stackchain/dashboard:587:", "status": "logged"
}]
assert output["cleared"] == 1
assert output["after"] is None
def test_recap_controller_exposes_per_item_partial_logging_results_for_retry():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const recap=createRecap({{
save:payload=>Promise.resolve(payload), clear:()=>{{}}, makeId:()=> 'partial-session',
saveAndLog:payload=>Promise.resolve({{time_logs:[
{{identity:payload.log_identities[0],status:'logged'}},
{{identity:payload.log_identities[1],status:'retry'}},
]}}),
}});
recap.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
recap.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:'])
.then(()=>{{throw new Error('expected retry');}})
.catch(error=>process.stdout.write(JSON.stringify({{message:error.message,draft:recap.snapshot()}})));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["message"] == "1 time entry logged; 1 needs retry."
assert output["draft"]["time_logs"] == {
"issue:stackchain/dashboard:587:": "logged",
"pull:stackchain/dashboard:586:": "retry",
}
def test_recap_partial_time_logging_intent_survives_reload_until_every_item_is_logged():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map(); const calls=[]; let attempt=0; let cleared=0;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const saveAndLog=payload=>{{
calls.push(payload.log_identities); attempt += 1;
return Promise.resolve({{time_logs:payload.log_identities.map((identity,index)=>({{
identity,status:attempt === 1 && index === 1 ? 'retry' : 'logged'
}}))}});
}};
const options={{save:payload=>Promise.resolve(payload),saveAndLog,clear:()=>{{cleared += 1;}},storage,getLogin:()=> 'timmy'}};
const first=createRecap({{...options,makeId:()=> 'durable-log'}});
first.begin([
{{identity:'issue:stackchain/dashboard:587:',elapsed_ms:20*60000}},
{{identity:'pull:stackchain/dashboard:586:',elapsed_ms:10*60000}},
]);
(async()=>{{
try {{ await first.saveAndLog(['issue:stackchain/dashboard:587:','pull:stackchain/dashboard:586:']); }} catch (_error) {{}}
const restored=createRecap(options);
const pending=restored.snapshot();
await restored.saveAndLog(['pull:stackchain/dashboard:586:']);
process.stdout.write(JSON.stringify({{pending,calls,cleared,after:restored.snapshot(),stored:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["pending"]["log_identities"] == [
"issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"
]
assert output["pending"]["time_logs"]["pull:stackchain/dashboard:586:"] == "retry"
assert output["calls"] == [
["issue:stackchain/dashboard:587:", "pull:stackchain/dashboard:586:"],
["pull:stackchain/dashboard:586:"],
]
assert output["cleared"] == 1
assert output["after"] is None
assert output["stored"] == []
def test_recap_feedback_uses_work_metadata_and_reports_per_item_variance():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const rows=createRecap.feedbackRows({{
items:[
{{identity:'issue:stackchain/dashboard:583:',estimate_minutes:30,actual_minutes:52}},
{{identity:'pull:stackchain/api:9:',estimate_minutes:null,actual_minutes:12}},
]
}}, identity => identity.startsWith('issue:') ? {{
title:'Turn recap feedback into a plan', key:'stackchain/dashboard#583'
}} : null);
process.stdout.write(JSON.stringify(rows));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == [
{
"identity": "issue:stackchain/dashboard:583:",
"label": "Turn recap feedback into a plan",
"context": "stackchain/dashboard#583",
"estimate_minutes": 30,
"actual_minutes": 52,
"variance_minutes": 22,
},
{
"identity": "pull:stackchain/api:9:",
"label": "pull:stackchain/api:9:",
"context": "Work details unavailable",
"estimate_minutes": None,
"actual_minutes": 12,
"variance_minutes": None,
},
]
def test_recap_controller_restores_corrected_draft_with_stable_retry_identity():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
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 options = {{
save:payload => Promise.resolve(payload), clear:() => {{}}, storage,
getLogin:() => ' Timmy ', makeId:() => 'stable-session',
}};
const first = createRecap(options);
first.begin([{{identity:'issue:r:1:',elapsed_ms:12*60000}}], {{'issue:r:1:':20}});
first.correct('issue:r:1:', 17);
const restored = createRecap({{...options, makeId:() => 'different-session'}}).snapshot();
process.stdout.write(JSON.stringify({{restored, keys:[...values.keys()]}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["restored"] == {
"session_id": "stable-session",
"items": [{"identity": "issue:r:1:", "estimate_minutes": 20, "actual_minutes": 17}],
"estimated_minutes": 20,
"actual_minutes": 17,
"variance_minutes": -3,
}
assert output["keys"] == ["stackchain.today-recap-draft.v1.timmy"]
def test_recap_controller_removes_corrupt_persisted_draft():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy';
const values=new Map([[key, '{{not-json']]);
const storage={{
getItem:key => values.has(key) ? values.get(key) : null,
setItem:(key,value) => values.set(key,value),
removeItem:key => values.delete(key),
}};
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{draft:recap.snapshot(),hasKey:values.has(key)}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"draft": None, "hasKey": False}
def test_recap_controller_restores_after_account_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map([['stackchain.today-recap-draft.v1.timmy', JSON.stringify({{
session_id:'offline-session', items:[{{identity:'issue:r:2:',estimate_minutes:null,actual_minutes:9}}]
}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='';
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login}});
const before=recap.snapshot(); login='timmy'; const didRestore=recap.restore(); const after=recap.snapshot();
process.stdout.write(JSON.stringify({{before,didRestore,after}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": None,
"didRestore": True,
"after": {
"session_id": "offline-session",
"items": [{"identity": "issue:r:2:", "estimate_minutes": None, "actual_minutes": 9}],
"estimated_minutes": 0,
"actual_minutes": 9,
"variance_minutes": 9,
},
}
def test_recap_controller_never_exposes_draft_after_account_switch():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='timmy';
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=>login,makeId:()=> 'timmy-session'}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
login='alexander';
process.stdout.write(JSON.stringify({{visible:recap.snapshot(),timmyDraft:values.has('stackchain.today-recap-draft.v1.timmy')}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"visible": None, "timmyDraft": True}
def test_recap_controller_keeps_failed_save_and_clears_only_after_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-draft.v1.timmy'; const values=new Map(); let cleared=0; let fail=true;
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve(payload),
clear:()=>{{cleared += 1;}}, storage, getLogin:()=> 'timmy', makeId:()=> 'retry-session'
}});
recap.begin([{{identity:'issue:r:1:',elapsed_ms:60000}}]);
(async()=>{{
try {{ await recap.save(); }} catch (_error) {{}}
const afterFailure={{draft:recap.snapshot(),stored:values.has(key),cleared}};
fail=false; await recap.save();
process.stdout.write(JSON.stringify({{afterFailure,afterSuccess:{{draft:recap.snapshot(),stored:values.has(key),cleared}}}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
output = json.loads(result.stdout)
assert output["afterFailure"]["draft"]["session_id"] == "retry-session"
assert output["afterFailure"]["stored"] is True
assert output["afterFailure"]["cleared"] == 0
assert output["afterSuccess"] == {"draft": None, "stored": False, "cleared": 1}
def test_recap_controller_returns_actuals_for_replanning_only_after_save_confirmation():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
let fail=true;
const recap=createRecap({{
save:payload => fail ? Promise.reject(new Error('offline')) : Promise.resolve({{saved:true}}),
clear:()=>{{}}, makeId:()=> 'replan-session'
}});
recap.begin([
{{identity:'issue:r:1:',elapsed_ms:52*60000}},
{{identity:'issue:r:2:',elapsed_ms:7*60000}},
], {{'issue:r:1:':30,'issue:r:2:':7}});
(async()=>{{
let failed;
try {{ await recap.saveForReplan(); }} catch (error) {{ failed=error.message; }}
const retained=recap.snapshot();
fail=false;
const confirmed=await recap.saveForReplan();
process.stdout.write(JSON.stringify({{failed,retained,confirmed,after:recap.snapshot()}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
assert output["failed"] == "offline"
assert output["retained"]["session_id"] == "replan-session"
assert output["confirmed"] == {
"result": {"saved": True},
"actual_minutes": {"issue:r:1:": 52, "issue:r:2:": 7},
}
assert output["after"] is None
def test_recap_controller_restores_account_scoped_pending_replan_until_completed():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const values=new Map();
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
let login='timmy';
const options={{save:payload=>Promise.resolve({{session_id:payload.session_id}}),clear:()=>{{}},storage,getLogin:()=>login}};
const first=createRecap({{...options,makeId:()=> 'saved-session'}});
first.begin([{{identity:'issue:r:1:',elapsed_ms:52*60000}}], {{'issue:r:1:':30}});
(async()=>{{
await first.saveForReplan();
const afterSave=first.pendingReplan();
const restored=createRecap(options).pendingReplan();
login='alexander';
const otherAccount=createRecap(options).pendingReplan();
login='timmy';
const retainedAfterCancel=createRecap(options).pendingReplan();
createRecap(options).completeReplan();
const afterComplete=createRecap(options).pendingReplan();
process.stdout.write(JSON.stringify({{afterSave,restored,otherAccount,retainedAfterCancel,afterComplete,keys:[...values.keys()]}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
output = json.loads(run.stdout)
pending = {
"session_id": "saved-session",
"actual_minutes": {"issue:r:1:": 52},
}
assert output == {
"afterSave": pending,
"restored": pending,
"otherAccount": None,
"retainedAfterCancel": pending,
"afterComplete": None,
"keys": [],
}
def test_recap_controller_removes_malformed_or_unbounded_pending_replan():
script = f"""
const createRecap = require({json.dumps(str(TODAY_RECAP))});
const key='stackchain.today-recap-handoff.v1.timmy';
const values=new Map([[key, JSON.stringify({{session_id:'saved',actual_minutes:Object.fromEntries(
Array.from({{length:21}}, (_,index)=>['issue:r:' + index + ':', 10])
)}})]]);
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
const recap=createRecap({{save:()=>Promise.resolve(),clear:()=>{{}},storage,getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{pending:recap.pendingReplan(),hasKey:values.has(key)}}));
"""
run = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert run.returncode == 0, run.stderr
assert json.loads(run.stdout) == {"pending": None, "hasKey": False}
@pytest.mark.anyio
async def test_dashboard_renders_mobile_today_recap_flow():
html = main.FRONTEND_BUILD.dashboard_html
css = (ROOT / "frontend" / "dashboard.css").read_text()
timer = (ROOT / "frontend" / "today-timer.js").read_text()
recap_source = TODAY_RECAP.read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
assert 'id="today-recap-sheet" role="dialog"' in html
assert 'id="today-recap-history"' in html
assert 'static/today-recap.js' in main.FRONTEND_BUILD.page_sources
assert "timer.recapEntries()" in recap_source
assert "isActive: () => workSession.checkpointed()" in dashboard
assert "openTodayRecapAfterSession(view" in recap_source
assert "api/v1/today/recaps" in recap_source
assert "storage:localStorage" in recap_source
assert "() => planningOwnerLogin" in dashboard
assert "recapEntries()" in timer and "clearRecap()" in timer
assert ".today-recap-header button { min-height:44px;" in css
assert ".today-recap-actions button { min-height:44px;" in css
assert "overflow-x:hidden" in css
assert "Save recap &amp; adjust plan" in html
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source
assert "adjustPlan(handoff.actual_minutes)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "openPlanToday(qs('#plan-today'), true, actualMinutes)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css
@pytest.mark.anyio
async def test_dashboard_resumes_and_explicitly_discards_pending_recap_feedback():
html = main.FRONTEND_BUILD.dashboard_html
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
recap_source = TODAY_RECAP.read_text()
assert 'id="discard-recap-replan"' in html
assert "todayRecapView.pendingReplan()?.actual_minutes" in dashboard
assert "todayRecapView.completeReplan();" in dashboard
assert "todayRecapView.discardReplan();" in dashboard
assert "if (capacityAware && !todayWork.replacePlanning(plan)) return false;" in dashboard
assert dashboard.index("todayWork.replacePlanning(plan)") < dashboard.index("todayRecapView.completeReplan();")
assert "pendingReplan:recap.pendingReplan" in recap_source
assert "discardReplan:recap.discardReplan" in recap_source
+170
View File
@@ -0,0 +1,170 @@
import json
import subprocess
from pathlib import Path
import pytest
from src import main
from src.today_store import TodayStore
ROLLOVER = Path(__file__).parents[1] / "frontend" / "today-rollover.js"
SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
def run_node(script):
return json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
def test_rollover_replaces_a_stale_plan_atomically_and_is_idempotent(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3", limit=3)
store.apply_batch("timmy", [
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:"},
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:"},
{"operation_id": "seed-plan", "action": "configure", "item_id": "plan",
"capacity_minutes": 180,
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45}},
])
operation = {
"operation_id": "roll-2026-08-13", "action": "rollover", "item_id": "plan",
"plan_date": "2026-08-13", "timezone": "America/New_York",
"ids": ["issue:r:2:"], "capacity_minutes": 120,
"estimates": {"issue:r:2:": 40}, "base_revision": 3,
}
rolled = store.apply_batch("timmy", [operation])
replay = store.apply_batch("timmy", [operation])
assert rolled == {
"revision": 4, "ids": ["issue:r:2:"], "capacity_minutes": 120,
"estimates": {"issue:r:2:": 40}, "plan_date": "2026-08-13",
"timezone": "America/New_York", "accepted_operation_ids": ["roll-2026-08-13"],
"duplicate_operation_ids": [], "rejected_operations": [],
}
assert replay["revision"] == 4
assert replay["duplicate_operation_ids"] == ["roll-2026-08-13"]
assert TodayStore(store.path).get("timmy")["plan_date"] == "2026-08-13"
edited = store.apply("timmy", "after-roll", "add", "issue:r:3:")
assert edited["plan_date"] == "2026-08-13"
assert edited["timezone"] == "America/New_York"
def test_stale_rollover_cannot_replace_a_newer_device_plan(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3")
store.apply("timmy", "seed", "add", "issue:r:1:")
store.apply("timmy", "newer", "add", "issue:r:2:")
result = store.apply_batch("timmy", [{
"operation_id": "stale-roll", "action": "rollover", "item_id": "plan",
"plan_date": "2026-08-13", "timezone": "UTC", "ids": ["issue:r:1:"],
"capacity_minutes": 60, "estimates": {"issue:r:1:": 30}, "base_revision": 1,
}])
assert result["ids"] == ["issue:r:1:", "issue:r:2:"]
assert result["rejected_operations"] == [
{"operation_id": "stale-roll", "reason": "stale_intent"}
]
def test_rollover_rejects_invalid_calendar_metadata_and_duplicate_items(tmp_path):
store = TodayStore(tmp_path / "today.sqlite3")
base = {"operation_id": "roll", "action": "rollover", "item_id": "plan",
"plan_date": "08/13/2026", "timezone": "UTC", "ids": []}
with pytest.raises(ValueError, match="plan_date"):
store.apply_batch("timmy", [base])
with pytest.raises(ValueError, match="unique"):
store.apply_batch("timmy", [{**base, "plan_date": "2026-08-13",
"ids": ["issue:r:1:", "issue:r:1:"]}])
def test_today_api_model_accepts_a_bounded_rollover():
operation = main.TodayOperation(
operation_id="roll", action="rollover", item_id="plan", base_revision=7,
plan_date="2026-08-13", timezone="America/New_York",
ids=["issue:r:1:"], capacity_minutes=120, estimates={"issue:r:1:": 45},
)
assert operation.model_dump()["plan_date"] == "2026-08-13"
assert operation.model_dump()["timezone"] == "America/New_York"
assert operation.model_dump()["ids"] == ["issue:r:1:"]
def test_same_day_is_current_but_prior_and_legacy_plans_require_review():
script = f"""
const create = require({json.dumps(str(ROLLOVER))});
const rollover = create({{ localDate:()=> '2026-08-13', timeZone:()=> 'America/New_York' }});
process.stdout.write(JSON.stringify([
rollover.reviewState({{plan_date:'2026-08-13',ids:['one']}}),
rollover.reviewState({{plan_date:'2026-08-12',ids:['one']}}),
rollover.reviewState({{ids:['one']}}),
rollover.reviewState({{ids:[]}}),
]));
"""
assert run_node(script) == ["current", "stale", "legacy", "empty"]
def test_rollover_preserves_selected_order_and_only_selected_estimates():
script = f"""
const create = require({json.dumps(str(ROLLOVER))});
const rollover = create({{localDate:()=> '2026-08-13',timeZone:()=> 'America/New_York'}});
process.stdout.write(JSON.stringify(rollover.operation({{
operation_id:'roll-1', base_revision:7, selected_ids:['two','one'], capacity_minutes:120,
estimates:{{one:30,two:45,removed:60}}
}})));
"""
assert run_node(script) == {
"operation_id": "roll-1", "action": "rollover", "item_id": "plan",
"base_revision": 7, "plan_date": "2026-08-13",
"timezone": "America/New_York", "ids": ["two", "one"],
"capacity_minutes": 120, "estimates": {"one": 30, "two": 45},
}
def test_calendar_day_comes_from_local_parts_not_elapsed_hours():
script = f"""
const create = require({json.dumps(str(ROLLOVER))});
const date = new Date('2026-11-01T05:30:00Z');
const rollover = create({{ now:()=>date, resolvedTimeZone:()=> 'America/New_York' }});
process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}}));
"""
assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"}
def test_rollover_is_one_durable_sync_operation_with_the_server_revision():
script = f"""
const createSync = require({json.dumps(str(SYNC))});
const createRollover = require({json.dumps(str(ROLLOVER))});
const values = new Map(); let delivered;
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const sync=createSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'roll-1',
fetchJson:async(_url,options)=>{{delivered=JSON.parse(options.body).operations;return {{revision:8,ids:['two'],plan_date:'2026-08-13',timezone:'UTC',accepted_operation_ids:['roll-1'],duplicate_operation_ids:[],rejected_operations:[]}}}},
onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
const rollover=createRollover({{localDate:()=> '2026-08-13',timeZone:()=> 'UTC'}});
const queued=sync.enqueueRollover(rollover.operation({{operation_id:'ignored',base_revision:7,
selected_ids:['two'],capacity_minutes:90,estimates:{{two:45}}}}));
(async()=>{{const before=sync.pending();await sync.flush();process.stdout.write(JSON.stringify({{queued,before,delivered,after:sync.pending()}}));}})();
"""
result = run_node(script)
assert result["queued"] is True
assert result["before"] == result["delivered"]
assert result["before"][0]["operation_id"] == "roll-1"
assert result["before"][0]["action"] == "rollover"
assert result["after"] == []
def test_rollover_review_is_wired_into_the_offline_mobile_shell():
root = Path(__file__).parents[1]
index = (root / "frontend" / "index.html").read_text()
worker = (root / "frontend" / "service-worker.js").read_text()
dashboard = (root / "frontend" / "dashboard.js").read_text()
css = (root / "frontend" / "dashboard.css").read_text()
assert '<script src="static/today-rollover.js"></script>' in index
assert "BASE + 'static/today-rollover.js'" in worker
assert "todayRollover.reviewState(plan)" in dashboard
assert "Review yesterday’s unfinished work" in dashboard
assert "todaySync.enqueueRollover" in dashboard
assert ".plan-today-header button { min-width:44px; min-height:44px;" in css
+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-v87" in source
assert "stackchain-dashboard-shell-v98" in source
assert "BASE + 'static/today-sync.js'" in source
+47 -3
View File
@@ -98,8 +98,52 @@ queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
"""
assert json.loads(run_node(script)) == {
"first": {"current_minutes": 30, "remaining_minutes": 135},
"second": {"current_minutes": 60, "remaining_minutes": 105},
"first": {
"current_minutes": 30, "remaining_minutes": 135,
"future_minutes": 105, "capacity_minutes": 180,
},
"second": {
"current_minutes": 60, "remaining_minutes": 105,
"future_minutes": 45, "capacity_minutes": 180,
},
}
def test_today_live_runway_uses_elapsed_work_and_reports_capacity_risk():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:']);
queue.replacePlanning({{capacity_minutes:100,estimates:{{'issue:r:1:':30,'issue:r:2:':60}}}});
const healthy = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:10*60000,total_elapsed_ms:10*60000}});
const over = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:45*60000,total_elapsed_ms:45*60000}});
process.stdout.write(JSON.stringify({{healthy,over}}));
"""
assert json.loads(run_node(script)) == {
"healthy": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
"over": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
}
def test_today_live_runway_stays_truthful_when_estimates_or_capacity_are_missing():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
const values = new Map();
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
const item = number => ({{kind:'issue',repository:'r',number}});
queue.replace(['issue:r:1:','issue:r:2:']);
queue.replacePlanning({{capacity_minutes:null,estimates:{{'issue:r:1:':30}}}});
process.stdout.write(JSON.stringify(queue.runway([item(1),item(2)],0,{{current_elapsed_ms:40*60000,total_elapsed_ms:40*60000}})));
"""
assert json.loads(run_node(script)) == {
"current_minutes": 30,
"remaining_minutes": None,
"future_minutes": None,
"capacity_minutes": None,
}
@@ -333,7 +377,7 @@ async def test_today_session_sheets_offer_a_touch_safe_plan_only_completion_acti
assert "document.querySelectorAll('[data-work-session-complete]')" in html
assert "completeTodayItem(selectedSessionItem(button.dataset.workSessionComplete))" in html
assert ".work-session-nav button { min-height:44px; width:100%; }" in html
assert "grid-template-columns:repeat(3,minmax(0,1fr))" in html
assert "grid-template-columns:repeat(2,minmax(0,1fr))" in html
@pytest.mark.anyio
+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