Compare commits

...
Author SHA1 Message Date
timmy 02a472f841 perf: bulk-authorize push devices (Closes #725)
CI / lint (pull_request) Successful in 1m35s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:29:18 +00:00
timmy c5e6e17fc1 Merge pull request 'Launch Protect Today directly from deadline reminders' (#724) from timmy/723-deadline-protect-today into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m35s
2026-08-13 08:59:34 +00:00
timmy 8a12524595 feat: launch Protect Today from deadline reminders (Closes #723)
CI / lint (pull_request) Successful in 1m22s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:57:32 +00:00
timmy cbe4017bdc Merge pull request 'Protect Today by reconciling urgent Agenda deadlines' (#722) from timmy/721-protect-today into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 08:30:35 +00:00
timmy fcc9ead33e feat: protect Today from urgent deadlines (Closes #721)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 08:27:01 +00:00
timmy 0d29b29bef Merge pull request 'Preserve deadline calendar days across mobile time zones' (#720) from timmy/719-calendar-day-deadlines into main
CI / build-release (push) Successful in 7s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m21s
2026-08-13 07:58:32 +00:00
timmy dce9ae04c9 fix: preserve deadline calendar days (Closes #719)
CI / lint (pull_request) Successful in 1m23s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:56:26 +00:00
timmy 9ec88179c3 Merge pull request 'Make deadline reminder polling eligibility-aware' (#718) from timmy/717-deadline-eligibility into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 07:29:20 +00:00
timmy 93890e72de test: synchronize deadline fanout assertion
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:27:22 +00:00
timmy 8f0df3d393 perf: gate deadline reminder polling (Closes #717)
CI / lint (pull_request) Failing after 1m27s
CI / build-release (pull_request) Has been skipped
CI / release-candidate (pull_request) Has been skipped
2026-08-13 07:23:05 +00:00
timmy fc79e4523d Merge pull request 'Configure deadline reminder time during mobile device setup' (#716) from timmy/configurable-deadline-device-setup into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m39s
2026-08-13 06:59:43 +00:00
timmy 741d545199 feat: configure deadline reminder time in device setup (Closes #715)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:57:39 +00:00
timmy 9320016b50 Merge pull request 'Run push notification channels on independent fixed cadences' (#714) from timmy/713-independent-push-cadences into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
2026-08-13 06:25:01 +00:00
timmy b069379df0 fix: isolate push channel schedules (Closes #713)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 06:22:58 +00:00
timmy 8387c57ceb Merge pull request 'Isolate and bound deadline reminder delivery' (#712) from timmy/711-isolate-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
2026-08-13 05:59:02 +00:00
timmy 72842ecbcd fix: isolate deadline reminder dispatch (Closes #711)
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:55:50 +00:00
timmy c503994be1 Merge pull request 'Send opt-in mobile deadline reminders into Agenda' (#710) from timmy/709-mobile-deadline-reminders into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 05:11:29 +00:00
timmy 5e6328a8d0 feat: send mobile deadline reminders (Closes #709)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 05:00:32 +00:00
timmy 4209834d6d Merge pull request 'Replan overdue Agenda deadlines in one mobile sweep' (#708) from timmy/707-mobile-agenda-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-13 04:29:06 +00:00
timmy 46888d2876 feat: replan overdue Agenda deadlines (Closes #707)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 04:26:53 +00:00
timmy 7d7abefb0b Merge pull request 'Surface upcoming deadlines in the mobile task dock' (#706) from timmy/705-mobile-deadline-dock into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m23s
2026-08-13 03:52:58 +00:00
timmy 2796808759 feat: surface mobile deadline urgency (Closes #705)
CI / lint (pull_request) Successful in 1m24s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:50:53 +00:00
timmy a2f83b0955 Merge pull request 'Make mobile Agenda discover every assigned deadline' (#704) from timmy/703-complete-mobile-agenda into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m25s
2026-08-13 03:27:33 +00:00
timmy 6b56188d30 feat: complete mobile Agenda discovery (Closes #703)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 03:25:25 +00:00
timmy 467c8c665d Merge pull request 'Launch fresh mobile work in the Agenda queue' (#702) from timmy/701-mobile-agenda-launch into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m28s
2026-08-13 02:53:37 +00:00
timmy 1b311c9cd4 feat: launch fresh mobile work in Agenda (Closes #701)
CI / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:51:25 +00:00
timmy 71d06347fd Merge pull request 'Keep Today and Later reads available during planning writes' (#700)
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m33s
2026-08-13 02:24:45 +00:00
timmy 1de6d73e75 perf: keep planning reads available during writes (Closes #699)
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 02:22:29 +00:00
timmy 46c75f0b22 Merge pull request 'Continue cleared mobile queues into Agenda deadlines' (#698) from timmy/697-agenda-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m25s
2026-08-13 01:55:57 +00:00
timmy 5cdb3f7b3e feat: continue cleared queues into Agenda (Closes #697)
CI / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:53:36 +00:00
timmy 15d197327a Merge pull request 'Keep cold Find Work startup reliable across workers' (#696) from timmy/695-cold-find-work-workers into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m36s
2026-08-13 01:25:01 +00:00
timmy df8385b325 fix: keep cold Find Work reliable across workers (Closes #695)
CI / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 01:22:42 +00:00
timmy 47c6412a12 Merge pull request 'Make the mobile Agenda addressable and Back-safe' (#694) from timmy/693-mobile-agenda-route into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 00:55:20 +00:00
timmy 6cac778acd feat: make mobile Agenda addressable (Closes #693)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:53:09 +00:00
timmy a8a93b2f3b Merge pull request 'Start work from an empty mobile queue' (#692) from timmy/691-empty-mobile-find-start into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m30s
2026-08-13 00:26:37 +00:00
timmy ef37a66fd7 feat: start work from an empty mobile queue (Closes #691)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:24:04 +00:00
timmy 52e2c26304 Merge pull request 'Stabilize recap bounded-concurrency verification' (#690) from timmy/689-stabilize-recap-concurrency-test 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 23:55:24 +00:00
timmy 3817d2c67b test: stabilize recap concurrency verification (Closes #689)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:52:56 +00:00
timmy ad6c1edf80 Merge pull request 'Isolate readiness probes from live Gitea traffic' (#688) from timmy/687-isolate-readiness 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 23:26:57 +00:00
timmy d2b2c7bada feat: isolate readiness probes from Gitea traffic (Closes #687)
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 9s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:10:29 +00:00
timmy 9ba03613d4 Merge pull request 'Plan upcoming deadlines from a mobile Agenda' (#686) from timmy/685-mobile-agenda into main
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
CI / lint (push) Failing after 1m40s
2026-08-12 22:30:56 +00:00
timmy 1132075318 feat: add mobile deadline Agenda (Closes #685)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 22:28:36 +00:00
timmy 16b1d8670f Merge pull request 'Continue mobile work across queue boundaries' (#684) from timmy/683-mobile-queue-continuation into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m38s
2026-08-12 21:59:42 +00:00
timmy e5c5c8ec31 feat: continue mobile work across queues (Closes #683)
CI / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-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 / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 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 / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 1m31s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 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 / lint (pull_request) Successful in 1m28s
CI / build-release (pull_request) Successful in 6s
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 / lint (pull_request) Successful in 1m30s
CI / build-release (pull_request) Successful in 5s
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 / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 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 / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 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 / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:56:42 +00:00
timmy fabd6123f8 Create follow-up and continue the Updates inbox (#666)
CI / 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 / lint (pull_request) Successful in 1m25s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 17:10:16 +00:00
timmy 681e10c7db Merge pull request 'Create follow-up work from unread updates' (#664) from timmy/663-update-follow-up into main
CI / 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 / lint (pull_request) Successful in 1m32s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:32:50 +00:00
timmy 97eb928187 Merge pull request 'Batch-plan work directly from mobile queues' (#662) from timmy/661-mobile-batch-planning into main
CI / 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 / lint (pull_request) Successful in 1m42s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 16:03:04 +00:00
timmy f04a547e19 Merge pull request 'Defer selected mobile updates as one unread batch' (#660) from timmy/659-defer-selected-updates into main
CI / 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 / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 15:11:51 +00:00
timmy 7421a8f62d Merge pull request 'Make every mobile Work queue addressable and Back-safe' (#658) from timmy/657-addressable-mobile-work-queues into main
CI / 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 / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 14:29:07 +00:00
timmy 3e7d6f7111 Merge pull request 'Isolate unexpected Web Push device failures' (#656) from timmy/655-isolate-push-device-failures into main
CI / 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 / lint (pull_request) Successful in 1m27s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:53:38 +00:00
timmy ea3194cdca Merge pull request 'Restrict Web Push delivery to public endpoints' (#654) from timmy/653-push-endpoint-egress-policy into main
CI / 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 / lint (pull_request) Successful in 1m46s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:29:50 +00:00
timmy dc202bde11 Merge pull request 'Consolidate mobile My Work actions into the thumb dock' (#652) from timmy/651-mobile-command-center into main
CI / 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 / lint (pull_request) Successful in 1m51s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 13:01:41 +00:00
timmy e35d7dea35 Merge pull request 'Undo accidental mobile update acknowledgements' (#650) from timmy/649-undo-update-acknowledgement into main
CI / 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 / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 12:34:55 +00:00
timmy 75da76c6f5 Merge pull request 'Open Web Push digests in the mobile Updates inbox' (#648) from timmy/647-push-updates-inbox into main
CI / 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 / lint (pull_request) Successful in 1m38s
CI / build-release (pull_request) Successful in 8s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:58:11 +00:00
timmy 8ce64d2aa7 Merge pull request 'Launch work directly from mobile queues' (#646) from timmy/645-mobile-queue-launcher into main
CI / 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 / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 11:26:35 +00:00
timmy 58260d8a26 Merge pull request 'Keep mobile inline review above the keyboard' (#644) from timmy/mobile-review-keyboard-safe into main
CI / 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 / lint (pull_request) Successful in 1m36s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:53:56 +00:00
timmy a87a5a4a90 Merge pull request 'Detect and surface atomic passkey counter anomalies' (#642) from timmy/641-passkey-counter-anomalies into main
CI / 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 / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 10:26:51 +00:00
timmy 5806d47b61 Merge pull request 'Expose unread Updates in the mobile queue switcher' (#640) from timmy/639-mobile-updates-queue into main
CI / 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 / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:52:41 +00:00
timmy c016261b1a Merge pull request 'Bound Build my Today readiness checks' (#638)
CI / 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 / lint (pull_request) Successful in 1m34s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 09:26:31 +00:00
timmy 9737300d55 Merge pull request 'Complete Build my Today with inline missing estimates' (#636) from timmy/635-inline-plan-estimates into main
CI / 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
71 changed files with 5898 additions and 285 deletions

No files matched your search

+17 -2
View File
@@ -199,6 +199,9 @@ export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
# three individual alerts followed by one private digest that opens Updates. A later
# comment on an already-delivered thread triggers a fresh alert when Gitea advances
# that thread's updated_at revision; unchanged and older snapshots remain silent.
# Browser push services must resolve exclusively to public IP addresses. Stackchain
# validates endpoints at enrollment and again before delivery, rejects redirects,
# and removes legacy subscriptions that resolve to private or reserved networks.
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
@@ -207,6 +210,10 @@ export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
# 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
# Unread updates and deadline reminders run on independent, fixed-cadence
# workers, so a slow channel cannot delay the other or add drift to its ticks.
# On each phone, Device Setup can enable deadline reminders and choose any local
# reminder hour from 00:00 through 23:00; the server-confirmed choice is restored.
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.
@@ -271,7 +278,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
@@ -365,7 +377,10 @@ account and never contain the Gitea token. Find Work uses the same worker-shared
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
bursts, and confirmed claims are removed from every worker's retained catalog. On a cold
catalog, non-owner workers wait within the bounded foreground deadline for the lease owner
to publish; if that owner releases or abandons the lease, a waiter can take over the scan
within its remaining request budget. Releasing
an assignment invalidates the shared catalog so the newly available issue can be discovered
by the next authoritative scan. Each bounded opaque revision token includes the
store generation, so a token from a different deployment or before replacement of the store
+83
View File
@@ -0,0 +1,83 @@
function createAgendaReplan({ now = () => new Date(), update }) {
let items = [];
let index = 0;
let pending = false;
let active = false;
const dayKey = date => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
const dueDay = value => {
const match = String(value || '').match(/^(\d{4}-\d{2}-\d{2})(?:$|T)/);
return match ? match[1] : '';
};
const current = () => active ? items[index] || null : null;
const snapshot = () => ({
active,
index,
total: items.length,
current: current()?.key || null,
pending,
});
const advance = () => {
index += 1;
if (index >= items.length) active = false;
return { ok:true, done:!active };
};
const change = async dueDate => {
const item = current();
if (!item) return { ok:false, error:'No overdue deadline selected.' };
if (pending) return { ok:false, error:'Deadline update already in progress.' };
pending = true;
try {
await update(item, dueDate);
return advance();
} catch (error) {
return { ok:false, error:error?.message || 'Deadline could not be updated.' };
} finally {
pending = false;
}
};
return {
start(overdue) {
items = (overdue || []).slice().sort((left, right) =>
dueDay(left.due_date).localeCompare(dueDay(right.due_date)) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
index = 0;
pending = false;
active = items.length > 0;
return snapshot();
},
snapshot,
current,
keep() {
if (pending) return Promise.resolve({ ok:false, error:'Deadline update already in progress.' });
if (!current()) return Promise.resolve({ ok:false, error:'No overdue deadline selected.' });
return Promise.resolve(advance());
},
tomorrow() {
const date = new Date(now());
date.setDate(date.getDate() + 1);
return change(dayKey(date) + 'T23:59:59Z');
},
choose(value) {
const chosen = String(value || '');
if (!/^\d{4}-\d{2}-\d{2}$/.test(chosen) || chosen <= dayKey(now())) {
return Promise.resolve({ ok:false, error:'Choose a future date.' });
}
return change(chosen + 'T23:59:59Z');
},
cancel() {
active = false;
pending = false;
return snapshot();
},
};
}
if (typeof module !== 'undefined' && module.exports) module.exports = createAgendaReplan;
+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;
+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,
};
}
+80 -4
View File
@@ -10,6 +10,9 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
.notification-undo { position:fixed; z-index:110; left:50%; bottom:calc(88px + env(safe-area-inset-bottom)); transform:translateX(-50%); box-sizing:border-box; width:min(520px,calc(100vw - 24px)); display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 12px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; box-shadow:0 12px 36px rgba(0,0,0,.5); overflow-wrap:anywhere; }
.notification-undo[hidden] { display:none; }
.notification-undo button { min-height:44px; min-width:64px; flex:none; }
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.draft-capacity-sheet[hidden] { display:none; }
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
@@ -35,6 +38,9 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.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-deadline-controls { display:flex; align-items:end; gap:8px; flex-wrap:wrap; }
.device-setup-deadline-controls label { font-size:12px; color:#bfdbfe; }
.device-setup-deadline-controls select, .device-setup-deadline-controls button, #push-deadline-hour { min-height:44px; }
.device-setup-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; }
@@ -74,6 +80,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
.empty-work-start[hidden] { display:none; }
.empty-work-start p { margin:6px 0 0; }
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
@media (max-width:420px) {
.empty-work-start-actions { grid-template-columns:1fr; }
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
@@ -205,6 +219,13 @@ 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; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
@@ -225,14 +246,30 @@ textarea { resize: vertical; min-height: 120px; }
.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); }
.agenda-replan { margin:10px 0; padding:12px; border:1px solid #7c4a1d; border-radius:12px; background:#24170d; }
.agenda-replan-launch { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.agenda-replan-launch p { margin:4px 0 0; }
.agenda-replan-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-top:10px; }
.agenda-replan-actions button { min-height:44px; width:100%; }
.agenda-replan-actions label { grid-column:1/-1; }
.agenda-replan-actions input { box-sizing:border-box; min-height:44px; width:100%; max-width:100%; }
.protect-today { margin:10px 0; padding:12px; border:1px solid #2f6f9f; border-radius:12px; background:#0d2136; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.protect-today p { margin:4px 0; }
.protect-today button { min-height:44px; flex:0 0 auto; }
@media(max-width:360px) { .agenda-replan-launch { align-items:stretch; flex-direction:column; } .agenda-replan-actions { grid-template-columns:1fr; } .agenda-replan-actions label { grid-column:auto; } }
@media(max-width:430px) { .protect-today { align-items:stretch; flex-direction:column; } .protect-today button { width:100%; } }
.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; }
.my-work-card:hover { border-color:var(--accent); }
.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; }
@@ -265,6 +302,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; }
@@ -436,9 +476,38 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
@@ -530,12 +599,14 @@ 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; }
.mobile-task-deadline { padding:1px 5px; border-radius:999px; color:#fef3c7; background:#92400e; font-size:10px; line-height:14px; }
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
.attention-interruption button { min-height:44px; flex:0 0 auto; }
@media (max-width: 600px) {
@@ -557,11 +628,11 @@ textarea { resize: vertical; min-height: 120px; }
.my-work { margin:0; }
.attention-interruption:not([hidden]) { position:sticky; top:64px; z-index:6; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); }
.my-work-header { align-items:flex-start; }
.my-work-actions { width:100%; flex-wrap:nowrap; }
.my-work-actions button { min-height:44px; flex:1 1 0; padding-inline:6px; }
.my-work-actions { display:none; }
.work-settings > summary { min-height:44px; display:flex; align-items:center; cursor:pointer; padding:0 10px; border:1px solid #2a496e; border-radius:10px; font-weight:700; }
.work-settings:not([open]) > .work-settings-panel { display:none; }
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
.queue-finder { position:sticky; top:64px; z-index:5; margin-inline:max(0px,env(safe-area-inset-left)) max(0px,env(safe-area-inset-right)); padding:8px; background:rgba(11,21,38,.98); border:1px solid #2a496e; border-radius:10px; }
.my-work-list { grid-template-columns:1fr; }
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
.my-work-card { min-width:0; overflow-x:hidden; }
@@ -593,14 +664,17 @@ 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; }
@@ -610,6 +684,8 @@ textarea { resize: vertical; min-height: 120px; }
.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-mobile-queue="agenda"][data-deadlines="true"] { border-color:#f59e0b; background:#30240f; box-shadow:inset 3px 0 #f59e0b; }
.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; }
+655 -79
View File
File diff suppressed because it is too large. Load diff
+123 -3
View File
@@ -82,6 +82,14 @@
<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>
<li class="device-setup-step">
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
<div class="device-setup-deadline-controls">
<label for="device-setup-deadline-hour">Local hour</label>
<select id="device-setup-deadline-hour" aria-label="Deadline reminder local hour"></select>
<button class="device-setup-action" id="device-setup-deadline" type="button">Enable</button>
</div>
</li>
</ol>
<p id="device-setup-ready-status" class="device-setup-ready" role="status" aria-live="polite"></p>
</section>
@@ -109,6 +117,7 @@
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
@@ -128,6 +137,9 @@
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
<label class="push-update-control" for="push-deadlines"><input id="push-deadlines" type="checkbox" /> Notify me about deadlines</label>
<label for="push-deadline-hour">Reminder hour <select id="push-deadline-hour" aria-label="Deadline reminder local hour"></select></label>
<span class="small" id="push-deadline-status" role="status" aria-live="polite"></span>
<button id="clear-offline-work" type="button">Clear offline work data</button>
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
@@ -163,10 +175,60 @@
</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>
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
<div>
<strong id="empty-work-start-heading">Ready for something new?</strong>
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
</div>
<div class="empty-work-start-actions">
<button id="empty-work-find" type="button">Find and start work</button>
<button id="empty-work-create" type="button">Create an issue</button>
</div>
</section>
<section class="agenda-replan" id="agenda-replan" aria-labelledby="agenda-replan-heading" hidden>
<div class="agenda-replan-launch">
<div><strong id="agenda-replan-heading">Overdue deadlines</strong><p class="small muted">Make the Agenda credible without leaving the queue.</p></div>
<button id="start-agenda-replan" type="button">Replan overdue</button>
</div>
<div id="agenda-replan-controls" hidden>
<div class="small" id="agenda-replan-progress" role="status" aria-live="assertive"></div>
<div class="agenda-replan-actions">
<button id="agenda-replan-keep" type="button">Keep date &amp; next</button>
<button id="agenda-replan-tomorrow" type="button">Tomorrow &amp; next</button>
<label for="agenda-replan-date">Choose a future date</label>
<input id="agenda-replan-date" type="date" />
<button id="agenda-replan-choose" type="button">Choose date &amp; next</button>
<button id="agenda-replan-cancel" type="button">Cancel</button>
</div>
</div>
</section>
<section class="protect-today" id="protect-today-panel" aria-labelledby="protect-today-heading" hidden>
<div>
<strong id="protect-today-heading">Deadlines need a plan</strong>
<p class="small muted">Put overdue and due-today work first, then review what fits.</p>
<div class="small" id="protect-today-status" role="status" aria-live="polite"></div>
</div>
<button id="protect-today" type="button">Protect Today</button>
</section>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
@@ -178,7 +240,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">
@@ -510,12 +579,44 @@
<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>
@@ -600,6 +701,7 @@
<div class="small">The issue will be assigned to you.</div>
<div class="create-issue-actions">
<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>
@@ -674,6 +776,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>
@@ -688,6 +791,11 @@
</section>
</div>
<div class="notification-undo" id="notification-undo" role="status" aria-live="assertive" hidden>
<span id="notification-undo-status"></span>
<button id="undo-notification" type="button" aria-label="Undo marking update read">Undo</button>
</div>
<div class="pull-sheet" id="pull-sheet" role="dialog" aria-modal="true" aria-labelledby="pull-sheet-title">
<section class="pull-sheet-panel">
<div class="pull-sheet-header">
@@ -860,9 +968,13 @@
<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="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">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>
@@ -872,7 +984,7 @@
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
<button class="mobile-task-action" data-mobile-task="new" type="button">New</button>
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></span><span class="mobile-task-deadline" id="mobile-deadline-count" hidden>0 due</span></button>
</nav>
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
@@ -898,7 +1010,11 @@
<script src="static/offline-work.js"></script>
<script src="static/offline-today.js"></script>
<script src="static/my-work.js"></script>
<script src="static/agenda-replan.js"></script>
<script src="static/protect-today.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>
@@ -907,16 +1023,19 @@
<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>
@@ -932,6 +1051,7 @@
<script src="static/context-poller.js"></script>
<script src="static/mobile-task-dock.js"></script>
<script src="static/mobile-work-entry.js"></script>
<script src="static/mobile-queue-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
+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;
+6 -1
View File
@@ -8,6 +8,7 @@
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline],
];
let trigger = options.launcher;
@@ -100,8 +101,10 @@ function mountMobileDeviceSetup(options) {
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'),
deadlineButton:qs('#device-setup-deadline'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-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,
@@ -118,9 +121,11 @@ function mountMobileDeviceSetup(options) {
: 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.'},
deadline:options.deadlineReadiness(),
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
enableDeadline:options.enableDeadline,
});
}
+3 -2
View File
@@ -4,13 +4,14 @@
else root.mobileLaunch = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const allowedFilters = new Set([
'all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft',
'all', 'today', 'attention', 'agenda', 'issue', 'pull', 'review', 'update', 'later', 'draft',
]);
function chooseFilter({ saved, today = 0, attention = 0 } = {}) {
function chooseFilter({ saved, today = 0, attention = 0, agenda = 0 } = {}) {
if (allowedFilters.has(saved)) return saved;
if (today > 0) return 'today';
if (attention > 0) return 'attention';
if (agenda > 0) return 'agenda';
return 'all';
}
+50
View File
@@ -0,0 +1,50 @@
(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'],
['agenda', 'Open Agenda'],
['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 };
});
+21 -7
View File
@@ -34,7 +34,7 @@
}
function closeQueues() {
if (options.queueSheet?.open) options.queueSheet.close();
options.queueSheet?.open && options.queueSheet.close();
}
function start() {
@@ -42,8 +42,7 @@
button.addEventListener('click', event => {
launcher = event.currentTarget;
select(name);
if (name === 'queues' && options.queueSheet) openQueues();
else options.actions[name]();
name === 'queues' && options.queueSheet ? openQueues() : options.actions[name]();
});
});
if (options.queueSheet) {
@@ -56,7 +55,7 @@
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
row.addEventListener('click', () => {
closeQueues();
options.onSelectQueue?.(name);
options.onSelectQueue?.(name, row);
});
});
}
@@ -70,6 +69,7 @@
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
find: ['Find', 'Find work'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
@@ -95,17 +95,31 @@
}
function updateQueues(counts) {
const names = ['today', 'attention', 'later', 'draft'];
const names = 'today agenda attention update later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = names.reduce((sum, name) => sum + normalized[name], 0);
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
options.queueRows?.update?.setAttribute(
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
}
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
if (options.deadlineBadge) {
options.deadlineBadge.textContent = normalized.agenda + ' due';
options.deadlineBadge.hidden = normalized.agenda === 0;
}
if (options.queueRows?.agenda) {
if (normalized.agenda > 0) options.queueRows.agenda.setAttribute('data-deadlines', 'true');
else options.queueRows.agenda.removeAttribute('data-deadlines');
}
const deadlineLabel = normalized.agenda > 0
? ', ' + normalized.agenda + ' upcoming deadlines'
: '';
buttons.queues?.setAttribute('aria-label', 'Queues, ' + total + ' items' + deadlineLabel);
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
+2 -2
View File
@@ -7,7 +7,7 @@
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
return 'work';
return 'find';
}
function open() {
@@ -16,7 +16,7 @@
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.openFallback();
else options.findWork();
return current;
}
+72 -11
View File
@@ -1,15 +1,26 @@
function issueDueState(dueDate, now) {
if (!dueDate) return null;
const due = new Date(dueDate);
if (Number.isNaN(due.getTime())) return null;
const day = value => value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
function calendarDay(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})(?:$|T)/);
if (!match) return '';
const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return date.getFullYear() === Number(match[1]) && date.getMonth() === Number(match[2]) - 1 &&
date.getDate() === Number(match[3]) ? match.slice(1, 4).join('-') : '';
}
function localDay(value) {
return value.getFullYear() + '-' + String(value.getMonth() + 1).padStart(2, '0') + '-' +
String(value.getDate()).padStart(2, '0');
const dueDay = day(due);
const today = day(now);
}
function issueDueState(dueDate, now) {
const dueDay = calendarDay(dueDate);
if (!dueDay) return null;
const today = localDay(now);
if (dueDay < today) return { label: 'Overdue', priority: 2 };
if (dueDay === today) return { label: 'Due today', priority: 2.5 };
return {
label: 'Due ' + due.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
label: 'Due ' + new Date(
Number(dueDay.slice(0, 4)), Number(dueDay.slice(5, 7)) - 1, Number(dueDay.slice(8, 10))
).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
priority: 4,
};
}
@@ -277,12 +288,13 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
function createWorkPager({ load, onItems, onPagination, onStatus }) {
let pagination = {};
const pending = new Set();
const completing = new Map();
const labels = {
issue: 'issues',
pull: 'pull requests',
review: 'review requests',
};
return {
const pager = {
reset(next) {
Object.entries(next || {}).forEach(([stream, value]) => {
const current = pagination[stream];
@@ -331,7 +343,20 @@ function createWorkPager({ load, onItems, onPagination, onStatus }) {
pending.delete(stream);
}
},
loadAll(stream, getExisting) {
if (completing.has(stream)) return completing.get(stream);
const completion = (async () => {
while (pagination[stream]?.has_more) {
const loaded = await pager.loadMore(stream, getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => completing.delete(stream));
completing.set(stream, completion);
return completion;
},
};
return pager;
}
function createNotificationReader({
@@ -362,7 +387,7 @@ function createNotificationReader({
onClose();
onStatus('Inbox cleared.');
}
return { items: updated, next: next || null };
return { items: updated, item: current, next: next || null };
}
async function open(item, savedDetail = null) {
@@ -404,7 +429,8 @@ function createNotificationReader({
return true;
},
acceptReadAndNext(items, item = selected) {
if (!item || selected !== item) return false;
if (!item || (selected && selected !== item)) return false;
selected = item;
return advanceAfterRead(items, item);
},
async loadOlder() {
@@ -552,6 +578,29 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
);
}
function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = localDay(start);
const tomorrowDate = new Date(start); tomorrowDate.setDate(tomorrowDate.getDate() + 1);
const tomorrow = localDay(tomorrowDate);
const afterTomorrowDate = new Date(start); afterTomorrowDate.setDate(afterTomorrowDate.getDate() + 2);
const afterTomorrow = localDay(afterTomorrowDate);
const horizonDate = new Date(start); horizonDate.setDate(horizonDate.getDate() + 7);
const horizon = localDay(horizonDate);
return (items || []).flatMap(item => {
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
const due = calendarDay(item.due_date);
if (!due || due >= horizon) return [];
const group = due < today ? 'Overdue' : due < tomorrow ? 'Today' :
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
return [{ ...item, agenda_group: group, _agenda_due: due }];
}).sort((left, right) =>
left._agenda_due.localeCompare(right._agenda_due) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
).map(({ _agenda_due, ...item }) => item);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
@@ -813,6 +862,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,
@@ -826,6 +885,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
@@ -836,6 +896,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;
+46
View File
@@ -0,0 +1,46 @@
function createProtectToday() {
function propose({ agenda = [], today = [], identity, limit = 5 }) {
const key = item => String(identity?.(item) || '');
const urgent = [];
const seen = new Set();
for (const item of [...agenda, ...today]) {
const id = key(item);
const eligible = item?.kind === 'issue' && item.is_assigned === true && item.state !== 'closed' &&
['Overdue', 'Today'].includes(item.agenda_group);
if (!eligible || !id || seen.has(id)) continue;
seen.add(id);
urgent.push(item);
}
urgent.sort((left, right) =>
String(left.due_date || '').localeCompare(String(right.due_date || '')) ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
);
if (!urgent.length) return {
selected:[], protected:[], displaced:[],
summary:'No overdue or due-today work needs protection.',
};
const existing = [];
for (const item of today) {
const id = key(item);
if (!id || seen.has(id)) continue;
seen.add(id);
existing.push(item);
}
const combined = [...urgent, ...existing];
const selected = combined.slice(0, limit);
const selectedIds = new Set(selected.map(key));
const displaced = today.filter(item => !selectedIds.has(key(item)) && !urgent.some(candidate => key(candidate) === key(item)));
return {
selected,
protected:urgent,
displaced,
summary:urgent.length + ' urgent ' + (urgent.length === 1 ? 'item' : 'items') + ' protected · ' +
displaced.length + (displaced.length ? ' displaced for review' : ' displaced'),
};
}
return { propose };
}
const protectToday = createProtectToday();
if (typeof module !== 'undefined' && module.exports) module.exports = protectToday;
+73 -4
View File
@@ -2,10 +2,23 @@
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createPushNotifications = factory;
})(typeof self !== 'undefined' ? self : this, function createPushNotifications({
control, status, notification, serviceWorker, fetchJson,
control, status, deadlineControl, deadlineStatus, deadlineHour, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
function formattedHour(value) {
return `${String(Number(value)).padStart(2, '0')}:00`;
}
function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
return configuration.deadline_enabled
? {state:'complete', detail:`Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`}
: {state:'incomplete', detail:deadlineStatus?.textContent || 'Choose when to receive deadline reminders.'};
}
function applicationServerKey(value) {
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
if (typeof atob === 'function') {
@@ -20,15 +33,17 @@
await fetchJson('api/v1/push-subscription', {method:'DELETE'});
await subscription?.unsubscribe?.();
control.checked = false;
if (deadlineControl) deadlineControl.checked = false;
status.textContent = 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = 'Deadline reminders are off for this device.';
}
async function enable() {
async function ensureSubscription() {
const permission = await notification.requestPermission();
if (permission !== 'granted') {
control.checked = false;
status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.';
return;
return null;
}
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
@@ -45,6 +60,12 @@
});
control.checked = true;
status.textContent = 'New update notifications enabled for this device.';
configuration.subscribed = true;
return subscription;
}
async function enable() {
await ensureSubscription();
}
async function change() {
@@ -60,20 +81,68 @@
}
}
async function changeDeadline() {
deadlineControl.disabled = true;
if (deadlineHour) deadlineHour.disabled = true;
try {
const registration = await serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (deadlineControl.checked && !subscription) subscription = await ensureSubscription();
if (deadlineControl.checked && !subscription) {
deadlineControl.checked = false;
deadlineStatus.textContent = status.textContent;
return false;
}
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const reminderHour = Number(deadlineHour?.value ?? configuration?.reminder_hour ?? 9);
await fetchJson('api/v1/push-subscription/deadlines', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour}),
});
configuration.deadline_enabled = deadlineControl.checked;
configuration.reminder_hour = reminderHour;
configuration.timezone = timezone;
deadlineStatus.textContent = deadlineControl.checked
? `Deadline reminders enabled for ${formattedHour(reminderHour)} local time.`
: 'Deadline reminders are off for this device.';
return true;
} catch (error) {
deadlineControl.checked = !deadlineControl.checked;
deadlineStatus.textContent = 'Could not change deadline reminders. Check your connection and try again.';
return false;
} finally {
deadlineControl.disabled = false;
if (deadlineHour) deadlineHour.disabled = false;
}
}
async function enableDeadline() {
deadlineControl.checked = true;
return changeDeadline();
}
async function init() {
if (!control || !notification || !serviceWorker) return;
control.addEventListener('change', change);
deadlineControl?.addEventListener('change', changeDeadline);
configuration = await fetchJson('api/v1/push-subscription');
if (!configuration.available) {
control.disabled = true;
if (deadlineControl) deadlineControl.disabled = true;
status.textContent = 'New update notifications are not available on this server.';
return;
}
control.checked = Boolean(configuration.subscribed);
if (deadlineControl) deadlineControl.checked = Boolean(configuration.deadline_enabled);
if (deadlineHour) deadlineHour.value = String(configuration.reminder_hour ?? 9);
status.textContent = configuration.subscribed
? 'New update notifications enabled for this device.'
: 'New update notifications are off for this device.';
if (deadlineStatus) deadlineStatus.textContent = configuration.deadline_enabled
? `Deadline reminders enabled for ${formattedHour(configuration.reminder_hour)} local time.`
: 'Deadline reminders are off for this device.';
}
return {init, change};
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
});
+4
View File
@@ -128,6 +128,7 @@
}
const labels = {
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
passkey_counter_anomaly: 'Passkey counter anomaly',
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
pull_review_approved: 'Pull request approved',
@@ -141,6 +142,9 @@
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [event.device_label, event.method, event.target,
event.kind === 'passkey_counter_anomaly'
? 'Remove and re-enroll this passkey if the alert repeats'
: null,
event.status === 'pending' ? 'Outcome confirmation pending' : null]
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
+49 -1
View File
@@ -1,6 +1,6 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v94';
const CACHE = 'stackchain-dashboard-shell-v99';
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;
@@ -32,7 +32,11 @@ const SHELL = [
BASE + 'static/offline-work.js',
BASE + 'static/offline-today.js',
BASE + 'static/my-work.js',
BASE + 'static/agenda-replan.js',
BASE + 'static/protect-today.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',
@@ -41,16 +45,19 @@ const SHELL = [
BASE + 'static/comment-next.js',
BASE + 'static/update-reply-read-next.js',
BASE + 'static/plan-today.js',
BASE + 'static/plan-today-readiness.js',
BASE + 'static/plan-today-preview.js',
BASE + 'static/today-sync.js',
BASE + 'static/today-rollover.js',
BASE + 'static/update-ownership.js',
BASE + 'static/update-follow-up.js',
BASE + 'static/later-work.js',
BASE + 'static/later-sync.js',
BASE + 'static/later-and-start.js',
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',
@@ -66,6 +73,7 @@ const SHELL = [
BASE + 'static/context-poller.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
@@ -306,9 +314,33 @@ self.addEventListener('push', event => {
try { payload = event.data?.json?.() || {}; }
catch (_error) { return; }
const route = String(payload.route || '');
const protectRoute = String(payload.protect_route || '');
const tag = String(payload.tag || '');
const notificationId = Number(payload.notification_id);
const updateCount = Number(payload.update_count);
const deadlineCount = Number(payload.deadline_count);
if (
route === '#/my-work/agenda'
&& protectRoute === '#/my-work/agenda/protect-today'
&& /^stackchain-deadline-digest-\d{4}-\d{2}-\d{2}$/.test(tag)
&& Number.isSafeInteger(deadlineCount)
&& deadlineCount > 0
&& deadlineCount <= 50
) {
event.waitUntil(self.registration.showNotification(
deadlineCount + ' deadline' + (deadlineCount === 1 ? '' : 's') + ' need' + (deadlineCount === 1 ? 's' : '') + ' attention',
{
body: 'Open Agenda to review or replan ' + (deadlineCount === 1 ? 'it.' : 'them.'),
tag,
actions: [
{ action: 'protect-today', title: 'Protect Today' },
{ action: 'open-agenda', title: 'Open Agenda' },
],
data: {route, protectRoute},
}
));
return;
}
if (
route === '#/my-work/updates'
&& tag === 'stackchain-update-digest'
@@ -354,6 +386,22 @@ async function openWorkRoute(route) {
self.addEventListener('notificationclick', event => {
const route = String(event.notification.data?.route || '');
if (event.action === 'protect-today') {
const protectRoute = String(event.notification.data?.protectRoute || route);
if (
protectRoute !== '#/my-work/agenda/protect-today'
|| !['#/my-work/agenda', protectRoute].includes(route)
) return;
event.notification.close();
event.waitUntil(openWorkRoute(protectRoute));
return;
}
if (event.action === 'open-agenda') {
if (route !== '#/my-work/agenda') return;
event.notification.close();
event.waitUntil(openWorkRoute(route));
return;
}
if (event.action === 'tomorrow') {
const notificationId = Number(event.notification.data?.notificationId);
if (
+16 -1
View File
@@ -91,6 +91,21 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
return write(ids) ? 'added' : 'unavailable';
}
function addMany(items) {
if (!storageKey()) return { status: 'unavailable', ids: [] };
const current = read();
const additions = [];
for (const item of items || []) {
const id = identity(item);
if (!id) return { status: 'unavailable', ids: [] };
if (!current.includes(id) && !additions.includes(id)) additions.push(id);
}
if (!additions.length) return { status: 'exists', ids: [] };
if (current.length + additions.length > limit) return { status: 'full', ids: [] };
return write(current.concat(additions)) ?
{ status: 'added', ids: additions } : { status: 'unavailable', ids: [] };
}
function replace(ids) {
const unique = [];
for (const id of ids || []) {
@@ -162,7 +177,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
};
}
return { identity, read, replace, planning, replacePlanning, runway, add, remove, move, reconcile, contains, position, limit };
return { identity, read, replace, planning, replacePlanning, runway, add, addMany, remove, move, reconcile, contains, position, limit };
}
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
+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;
+29
View File
@@ -6,6 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@@ -15,6 +16,13 @@
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 (queue === 'agenda' && parts[3] === 'protect-today' && parts.length === 4) {
return { kind: 'queue', filter: 'agenda', action: 'protect-today' };
}
if (parts[2] === 'update' && parts.length === 4) {
const notificationId = positiveInteger(parts[3]);
return notificationId ? { kind: 'update', notification_id: notificationId } : null;
@@ -38,6 +46,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 +57,7 @@
function createController({
location, history, eventTarget, onOpen, onClose, onInvalid,
onQueue = function () {},
resolve, onResolving = function () {}, onError = function () {},
}) {
let items = [];
@@ -100,6 +110,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, route.action || null);
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
if (!item) {
if (ready) resolveMissing(fragment, route);
@@ -134,6 +154,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;
+8
View File
@@ -244,6 +244,14 @@ async def managed_session_active(management_id: str) -> bool:
return status == "active"
async def managed_session_statuses(management_ids) -> dict[str, str]:
return await asyncio.to_thread(
_session_store().managed_statuses,
management_ids,
idle_timeout_seconds=idle_timeout_seconds(),
)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
+5 -4
View File
@@ -22,15 +22,16 @@ COMMONJS_BROWSER_BRANCH = re.compile(
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
FEATURE_SOURCES = {
"comment-actions": ("static/conversation.js", "static/comment-actions.js"),
"issue-capture": ("static/create-issue-sheet.js",),
"issue-capture": ("static/create-issue-sheet.js", "static/update-follow-up.js"),
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
"push-notifications": ("static/push-notifications.js",),
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js",
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/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(
+19
View File
@@ -403,6 +403,17 @@ async def work_page(stream: str, page: int = 1, limit: int = 50) -> dict:
}
async def assigned_issue_snapshot(*, limit: int = 50, max_pages: int = 20) -> dict:
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
items = []
for page in range(1, max_pages + 1):
result = await work_page("issue", page, limit)
items.extend(result["items"])
if not result["has_more"]:
return {"items": items, "complete": True}
return {"items": [], "complete": False}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict):
return None
@@ -804,6 +815,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):
+10 -2
View File
@@ -22,10 +22,14 @@ class LaterStore:
self.operation_limit = operation_limit
self.operation_retention_seconds = operation_retention_seconds
self.clock = clock
self._initialize()
def _connect(self) -> sqlite3.Connection:
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
@@ -63,8 +67,12 @@ class LaterStore:
"UPDATE later_operations SET created_at = ? WHERE created_at IS NULL",
(self.clock(),),
)
connection.execute("PRAGMA user_version = 1")
connection.commit()
return connection
connection.close()
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
now = self.clock()
+283 -45
View File
@@ -10,6 +10,7 @@ import sqlite3
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
@@ -47,7 +48,12 @@ 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_notifications import (
PushConfiguration,
dispatch_deadline_reminders,
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
@@ -58,6 +64,9 @@ from src.views import FRONTEND_BUILD, router as frontend_router
async def _drain_authored_action_operations() -> None:
for key, operation in list(_authored_action_operations.items()):
if operation[1].done():
_authored_action_operations.pop(key, None)
tasks = {
operation[1]
for operation in _authored_action_operations.values()
@@ -78,8 +87,28 @@ async def _drain_authored_action_operations() -> None:
_authored_action_operations.pop(key, None)
async def _push_channel_loop(dispatch, *, interval: float) -> None:
"""Run one push channel on fixed ticks without overlapping or catch-up bursts."""
loop = asyncio.get_running_loop()
next_tick = loop.time() + interval
while True:
await asyncio.sleep(max(0.0, next_tick - loop.time()))
try:
await dispatch()
except asyncio.CancelledError:
raise
except Exception:
pass
now = loop.time()
elapsed_intervals = max(1, int((now - next_tick) // interval) + 1)
next_tick += elapsed_intervals * interval
async def _push_poll_loop() -> None:
interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")))
deadline_interval = max(
60.0, float(os.getenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600"))
)
send_timeout = max(
1.0, float(os.getenv("STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS", "10"))
)
@@ -94,31 +123,59 @@ async def _push_poll_loop() -> None:
0,
int(os.getenv("STACKCHAIN_PUSH_MAX_INDIVIDUAL_NOTIFICATIONS", "3")),
)
while True:
await asyncio.sleep(interval)
try:
await dispatch_unread_updates(
async def dispatch_unread() -> None:
await dispatch_unread_updates(
_push_subscription_store,
_push_configuration(),
gitea_proxy.unread_notification_snapshot,
session_active=dashboard_auth.managed_session_active,
session_statuses=dashboard_auth.managed_session_statuses,
lease_seconds=lease_seconds,
send_timeout_seconds=send_timeout,
max_concurrency=max_concurrency,
max_individual_notifications=max_individual_notifications,
)
except asyncio.CancelledError:
raise
except Exception:
# Gitea and push endpoints are external; one failed poll must not
# stop later delivery attempts.
continue
async def dispatch_deadlines() -> None:
await dispatch_deadline_reminders(
_push_subscription_store,
_push_configuration(),
gitea_proxy.assigned_issue_snapshot,
session_statuses=dashboard_auth.managed_session_statuses,
send_timeout_seconds=send_timeout,
lease_seconds=lease_seconds,
max_concurrency=max_concurrency,
)
channel_tasks = (
asyncio.create_task(_push_channel_loop(dispatch_unread, interval=interval)),
asyncio.create_task(
_push_channel_loop(dispatch_deadlines, interval=deadline_interval)
),
)
try:
done, _pending = await asyncio.wait(
channel_tasks, return_when=asyncio.FIRST_COMPLETED
)
await next(iter(done))
finally:
for task in channel_tasks:
if not task.done():
task.cancel()
await asyncio.gather(*channel_tasks, return_exceptions=True)
async def _readiness_monitor() -> None:
while True:
await _check_readiness()
await asyncio.sleep(READINESS_INTERVAL_SECONDS)
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task
global _readiness_task
gitea_proxy.start_client()
_readiness_task = asyncio.create_task(_readiness_monitor())
if _push_configuration().enabled:
_push_poll_task = asyncio.create_task(_push_poll_loop())
try:
@@ -127,7 +184,8 @@ async def lifespan(_app: FastAPI):
live_task = _live_snapshot_task
available_task = _available_issue_snapshot_task
push_task = _push_poll_task
for task in (live_task, available_task, push_task):
readiness_task = _readiness_task
for task in (live_task, available_task, push_task, readiness_task):
if task is not None and not task.done():
task.cancel()
try:
@@ -144,6 +202,8 @@ async def lifespan(_app: FastAPI):
_available_issue_snapshot_task = None
if _push_poll_task is push_task:
_push_poll_task = None
if _readiness_task is readiness_task:
_readiness_task = None
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
@@ -152,6 +212,60 @@ app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
READINESS_INTERVAL_SECONDS = max(
READINESS_TIMEOUT_SECONDS,
float(os.getenv("STACKCHAIN_READINESS_INTERVAL_SECONDS", "30")),
)
READINESS_MAX_AGE_SECONDS = max(
READINESS_INTERVAL_SECONDS + READINESS_TIMEOUT_SECONDS + 5.0,
float(os.getenv("STACKCHAIN_READINESS_MAX_AGE_SECONDS", "65")),
)
@dataclass(frozen=True)
class ReadinessState:
checked_at: float
ready: bool
login: str | None = None
error: str | None = None
timed_out: bool = False
_readiness_state: ReadinessState | None = None
async def _check_readiness() -> None:
global _readiness_state
checked_at = time.monotonic()
try:
user = await asyncio.wait_for(current_user(), timeout=READINESS_TIMEOUT_SECONDS)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
_readiness_state = ReadinessState(
checked_at=checked_at,
ready=False,
error=error,
timed_out=timed_out,
)
return
_readiness_state = ReadinessState(
checked_at=checked_at,
ready=True,
login=user["login"],
)
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
@@ -180,6 +294,7 @@ AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_push_poll_task: asyncio.Task | None = None
_readiness_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
_live_snapshot_created_at: float | None = None
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
@@ -275,6 +390,23 @@ class PushSubscriptionPayload(BaseModel):
return value
class DeadlineReminderPayload(BaseModel):
enabled: bool
timezone: str = Field(min_length=1, max_length=64)
reminder_hour: int = Field(default=9, ge=0, le=23)
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as error:
raise ValueError("Valid IANA timezone required") from error
return value
StepUpAction = Literal[
"merge_pull",
"submit_pull_review",
@@ -1595,9 +1727,19 @@ async def verify_passkey_authorization(
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
store.advance_counter,
stored.credential_id,
expected=stored.sign_count,
new=verified.new_sign_count,
)
if not updated:
await asyncio.to_thread(
_security_event_store().record,
"passkey_counter_anomaly",
method="passkey",
device_label=stored.device_label,
target=f"{payload.action}:{payload.target}",
)
raise ValueError("stale passkey counter")
grant = await dashboard_auth.issue_step_up(
request.state.dashboard_session,
@@ -1686,9 +1828,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(
@@ -1777,10 +1929,16 @@ async def push_status(request: Request):
_push_subscription_store.is_subscribed,
device_id,
)
preferences = await asyncio.to_thread(
_push_subscription_store.deadline_preferences, device_id
)
return {
"available": configuration.enabled,
"subscribed": subscribed,
"public_key": configuration.public_key if configuration.enabled else "",
"deadline_enabled": preferences["enabled"],
"timezone": preferences["timezone"],
"reminder_hour": preferences["reminder_hour"],
}
@@ -1788,6 +1946,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
)
@@ -1830,6 +1992,29 @@ async def unsubscribe_push(request: Request):
return {"subscribed": False}
@app.put("/api/v1/push-subscription/deadlines")
async def update_deadline_reminders(payload: DeadlineReminderPayload, request: Request):
device_id = await dashboard_auth.session_management_id(
request.state.dashboard_session
)
if payload.enabled and not await asyncio.to_thread(
_push_subscription_store.is_subscribed, device_id
):
raise HTTPException(status_code=409, detail="Enable device notifications first")
await asyncio.to_thread(
_push_subscription_store.set_deadline_preferences,
device_id,
enabled=payload.enabled,
timezone=payload.timezone,
reminder_hour=payload.reminder_hour,
)
return {
"deadline_enabled": payload.enabled,
"timezone": payload.timezone,
"reminder_hour": payload.reminder_hour,
}
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
@@ -2333,43 +2518,35 @@ async def sign_out_all_devices(
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
try:
user = await asyncio.wait_for(
current_user(), timeout=READINESS_TIMEOUT_SECONDS
)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error_message = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
"""Return the latest readiness snapshot without contacting Gitea."""
state = _readiness_state
if state is None or time.monotonic() - state.checked_at > READINESS_MAX_AGE_SECONDS:
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"error": error_message,
"error": "Gitea readiness status is not available",
},
status_code=503,
)
if not state.ready:
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"error": state.error,
},
status_code=503,
headers={
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
} if timed_out else None,
} if state.timed_out else None,
)
payload = {
"status": "ready",
"service": "stackchain-dashboard",
}
if not dashboard_auth.enabled():
payload["gitea_user"] = user["login"]
payload["gitea_user"] = state.login
return payload
@@ -2680,14 +2857,18 @@ async def _available_issue_snapshot() -> tuple[list[dict], bool, bool, bool]:
if _available_issue_snapshot_value is not None:
return _available_issue_snapshot_value, True, True, False
if not local_refresh:
for _ in range(50):
wait_deadline = time.monotonic() + WORK_PAGE_TIMEOUT_SECONDS
while time.monotonic() < wait_deadline:
await asyncio.sleep(0.02)
shared = await asyncio.to_thread(_available_issue_snapshot_store.load)
if shared.items is not None:
return shared.items, False, False, False
if not shared.refreshing:
break
raise RuntimeError("available issue catalog refresh is owned by another worker")
local_refresh = await _start_available_issue_refresh()
if local_refresh:
break
if not local_refresh:
raise RuntimeError("available issue catalog refresh is owned by another worker")
try:
return await asyncio.shield(_available_issue_snapshot_task), False, False, False
except Exception:
@@ -2696,19 +2877,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
@@ -2719,7 +2934,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:
@@ -3432,6 +3649,27 @@ async def read_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/unread")
async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mark_notification_unread(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Restoring the update timed out. Retry Undo."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "The update could not be restored. Retry Undo."},
status_code=503,
)
return JSONResponse({"id": thread_id, "status": "unread"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
+17 -4
View File
@@ -225,13 +225,26 @@ class PasskeyStore:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
def advance_counter(
self, credential_id: bytes, *, expected: int, new: int
) -> bool:
if expected == 0 and new == 0:
comparison = "sign_count = 0"
elif new > expected:
comparison = "sign_count = ?"
else:
return False
try:
with self._connect() as connection:
parameters = (
(new, credential_id)
if expected == 0 and new == 0
else (new, credential_id, expected)
)
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
f"UPDATE passkey_credentials SET sign_count = ? "
f"WHERE credential_id = ? AND {comparison}",
parameters,
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
+62
View File
@@ -0,0 +1,62 @@
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from urllib.parse import urlsplit
class UnsafePushEndpoint(ValueError):
"""Raised when a Web Push endpoint could reach a non-public service."""
Resolver = Callable[[str, int], Awaitable[list[str]]]
async def _resolve(host: str, port: int) -> list[str]:
loop = asyncio.get_running_loop()
records = await loop.getaddrinfo(
host,
port,
family=socket.AF_UNSPEC,
type=socket.SOCK_STREAM,
)
return sorted({record[4][0] for record in records})
async def validate_public_push_endpoint(
endpoint: str,
*,
resolver: Resolver = _resolve,
timeout_seconds: float = 2.0,
) -> str:
"""Return a canonical public HTTPS push endpoint or fail closed."""
parsed = urlsplit(endpoint)
try:
port = parsed.port
except ValueError as error:
raise UnsafePushEndpoint(
"Endpoint must be a canonical public Web Push service URL"
) from error
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
or port not in {None, 443}
):
raise UnsafePushEndpoint("Endpoint must be a canonical public Web Push service URL")
try:
addresses = await asyncio.wait_for(
resolver(parsed.hostname, port or 443),
timeout=max(0.01, timeout_seconds),
)
except (OSError, asyncio.TimeoutError) as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
try:
public = addresses and all(ipaddress.ip_address(address).is_global for address in addresses)
except ValueError as error:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service") from error
if not public:
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
return endpoint
+200 -9
View File
@@ -3,9 +3,12 @@ import json
import secrets
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint, validate_public_push_endpoint
@dataclass(frozen=True)
@@ -22,8 +25,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 +38,8 @@ async def send_web_push(
vapid_private_key=configuration.private_key,
vapid_claims={"sub": configuration.subject},
ttl=300,
timeout=10,
requests_session=session,
)
@@ -40,11 +49,12 @@ async def dispatch_unread_updates(
unread: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
session_active: Callable[[str], Awaitable[bool]] | None = None,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | 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
@@ -52,6 +62,7 @@ async def dispatch_unread_updates(
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
@@ -68,20 +79,22 @@ async def dispatch_unread_updates(
}
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:
if session_statuses is not None:
try:
authorized = [await session_active(item.session_id) for item in deliveries]
statuses = await session_statuses(
[item.session_id for item in deliveries]
)
except Exception:
# Authorization state is mandatory for delivery. Preserve subscriptions
# so a temporary registry failure can be retried safely.
return 0
for delivery, active in zip(deliveries, authorized):
if not active:
for delivery in deliveries:
if statuses.get(delivery.session_id) != "active":
await asyncio.to_thread(store.delete_session, delivery.session_id)
deliveries = [
delivery
for delivery, active in zip(deliveries, authorized)
if active
for delivery in deliveries
if statuses.get(delivery.session_id) == "active"
]
semaphore = asyncio.Semaphore(max(1, max_concurrency))
ownership_lost = asyncio.Event()
@@ -90,6 +103,15 @@ async def dispatch_unread_updates(
async with semaphore:
if ownership_lost.is_set():
return 0
validate_endpoint = endpoint_validator
if validate_endpoint is None and send is None:
validate_endpoint = validate_public_push_endpoint
try:
if validate_endpoint is not None:
await validate_endpoint(delivery.subscription["endpoint"])
except UnsafePushEndpoint:
await asyncio.to_thread(store.delete_session, delivery.session_id)
return 0
count = 0
digest_pending = set(delivery.digest_revisions)
new_revisions = tuple(
@@ -109,6 +131,7 @@ async def dispatch_unread_updates(
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
@@ -155,6 +178,7 @@ async def dispatch_unread_updates(
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="unread",
now=time.time(),
lease_seconds=lease_seconds,
)
@@ -202,9 +226,176 @@ async def dispatch_unread_updates(
count += 1
return count
async def dispatch_device_safely(delivery) -> int:
try:
return await dispatch_device(delivery)
except Exception:
# Device-specific validation, persistence, or provider failures
# must not cancel healthy siblings. Leave any uncheckpointed
# revisions unseen so a later poll can retry them.
return 0
counts = await asyncio.gather(
*(dispatch_device(delivery) for delivery in deliveries)
*(dispatch_device_safely(delivery) for delivery in deliveries)
)
return sum(counts)
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner)
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="unread")
async def dispatch_deadline_reminders(
store: PushSubscriptionStore,
configuration: PushConfiguration,
assigned: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
**kwargs,
) -> int:
owner = secrets.token_urlsafe(18)
acquired = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="deadline",
now=time.time(),
lease_seconds=max(
15.0,
float(kwargs.get("lease_seconds", 60.0)),
float(kwargs.get("send_timeout_seconds", 10.0)) + 5.0,
),
)
if not acquired:
return 0
try:
return await _dispatch_deadline_reminders_unlocked(
store, configuration, assigned, send, owner=owner, **kwargs
)
finally:
await asyncio.to_thread(store.release_dispatch_lease, owner, channel="deadline")
async def _dispatch_deadline_reminders_unlocked(
store: PushSubscriptionStore,
configuration: PushConfiguration,
assigned: Callable[[], Awaitable[dict]],
send: Callable[[dict, str], Awaitable[None]] | None = None,
*,
now: datetime | None = None,
session_statuses: Callable[[list[str]], Awaitable[dict[str, str]]] | None = None,
send_timeout_seconds: float = 10.0,
lease_seconds: float = 60.0,
max_concurrency: int = 8,
owner: str,
) -> int:
"""Send one privacy-safe Agenda digest per eligible device and local day."""
if not configuration.enabled:
return 0
devices = await asyncio.to_thread(store.deadline_reminder_devices)
if not devices:
return 0
current = now or datetime.now(timezone.utc)
eligible_devices = []
for device in devices:
try:
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
continue
if (
local_now.hour >= device.reminder_hour
and device.delivered_local_day != local_now.date().isoformat()
):
eligible_devices.append(device)
if not eligible_devices:
return 0
snapshot = await assigned()
if snapshot.get("complete") is False:
return 0
due_days = []
for item in snapshot.get("items", []):
if not isinstance(item, dict) or not item.get("due_date"):
continue
raw_due = str(item["due_date"])
try:
due_day = datetime.strptime(raw_due[:10], "%Y-%m-%d").date()
except ValueError:
continue
due_days.append(due_day)
if not due_days:
return 0
due_counts = {}
for device in eligible_devices:
local_now = current.astimezone(ZoneInfo(device.timezone))
local_cutoff = local_now.date() + timedelta(days=2)
due_count = sum(due_day <= local_cutoff for due_day in due_days)
if due_count:
due_counts[device.session_id] = due_count
eligible_devices = [
device for device in eligible_devices if device.session_id in due_counts
]
if not eligible_devices:
return 0
if session_statuses is not None:
try:
statuses = await session_statuses(
[device.session_id for device in eligible_devices]
)
except Exception:
return 0
for device in eligible_devices:
if statuses.get(device.session_id) != "active":
await asyncio.to_thread(store.delete_session, device.session_id)
eligible_devices = [
device
for device in eligible_devices
if statuses.get(device.session_id) == "active"
]
if not eligible_devices:
return 0
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def dispatch_device(device) -> int:
async with semaphore:
try:
local_now = current.astimezone(ZoneInfo(device.timezone))
except ZoneInfoNotFoundError:
return 0
local_day = local_now.date().isoformat()
if (
local_now.hour < device.reminder_hour
or device.delivered_local_day == local_day
):
return 0
due_count = due_counts[device.session_id]
still_owner = await asyncio.to_thread(
store.acquire_dispatch_lease,
owner,
channel="deadline",
now=time.time(),
lease_seconds=max(15.0, lease_seconds, send_timeout_seconds + 5.0),
)
if not still_owner:
return 0
payload = json.dumps({
"title": f"{due_count} deadline{'s' if due_count != 1 else ''} need{'s' if due_count == 1 else ''} attention",
"body": f"Open Agenda to review or replan {'it' if due_count == 1 else 'them'}.",
"route": "#/my-work/agenda",
"protect_route": "#/my-work/agenda/protect-today",
"tag": f"stackchain-deadline-digest-{local_day}",
"deadline_count": due_count,
}, separators=(",", ":"))
try:
operation = (
send(device.subscription, payload)
if send is not None
else send_web_push(device.subscription, payload, configuration)
)
await asyncio.wait_for(operation, timeout=send_timeout_seconds)
except Exception:
return 0
await asyncio.to_thread(
store.mark_deadline_reminder_delivered, device.session_id, local_day
)
return 1
results = await asyncio.gather(
*(dispatch_device(device) for device in eligible_devices), return_exceptions=True
)
return sum(result for result in results if isinstance(result, int))
+97 -10
View File
@@ -22,6 +22,15 @@ class PushDelivery:
return tuple(thread_id for thread_id, _revision in self.digest_revisions)
@dataclass(frozen=True)
class DeadlineReminderDevice:
session_id: str
subscription: dict
timezone: str
reminder_hour: int
delivered_local_day: str | None
def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
@@ -66,7 +75,7 @@ class PushSubscriptionStore:
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_dispatch_lease (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
channel TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
@@ -78,6 +87,15 @@ class PushSubscriptionStore:
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_deadline_preferences (
session_id TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 0,
timezone TEXT NOT NULL DEFAULT 'UTC',
reminder_hour INTEGER NOT NULL DEFAULT 9,
delivered_local_day TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@@ -95,6 +113,24 @@ class PushSubscriptionStore:
connection.execute(
"ALTER TABLE push_digest_pending ADD COLUMN revision TEXT NOT NULL DEFAULT '*'"
)
lease_columns = {
row[1] for row in connection.execute("PRAGMA table_info(push_dispatch_lease)")
}
if "singleton" in lease_columns:
connection.executescript(
"""
ALTER TABLE push_dispatch_lease RENAME TO push_dispatch_lease_legacy;
CREATE TABLE push_dispatch_lease (
channel TEXT PRIMARY KEY,
owner TEXT NOT NULL,
expires_at REAL NOT NULL
);
INSERT INTO push_dispatch_lease(channel, owner, expires_at)
SELECT 'unread', owner, expires_at
FROM push_dispatch_lease_legacy WHERE singleton = 1;
DROP TABLE push_dispatch_lease_legacy;
"""
)
os.chmod(self.path, 0o600)
def _connect(self):
@@ -103,29 +139,30 @@ class PushSubscriptionStore:
return connection
def acquire_dispatch_lease(
self, owner: str, *, now: float, lease_seconds: float
self, owner: str, *, channel: str = "unread", now: float, lease_seconds: float
) -> bool:
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT owner, expires_at FROM push_dispatch_lease WHERE singleton = 1"
"SELECT owner, expires_at FROM push_dispatch_lease WHERE channel = ?",
(channel,),
).fetchone()
if current is not None and current[0] != owner and current[1] > now:
return False
connection.execute(
"""INSERT INTO push_dispatch_lease(singleton, owner, expires_at)
VALUES (1, ?, ?)
ON CONFLICT(singleton) DO UPDATE SET
"""INSERT INTO push_dispatch_lease(channel, owner, expires_at)
VALUES (?, ?, ?)
ON CONFLICT(channel) DO UPDATE SET
owner = excluded.owner, expires_at = excluded.expires_at""",
(owner, now + lease_seconds),
(channel, owner, now + lease_seconds),
)
return True
def release_dispatch_lease(self, owner: str) -> bool:
def release_dispatch_lease(self, owner: str, *, channel: str = "unread") -> bool:
with self._connect() as connection:
result = connection.execute(
"DELETE FROM push_dispatch_lease WHERE singleton = 1 AND owner = ?",
(owner,),
"DELETE FROM push_dispatch_lease WHERE channel = ? AND owner = ?",
(channel, owner),
)
return result.rowcount == 1
@@ -154,6 +191,56 @@ class PushSubscriptionStore:
"SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,)
).fetchone() is not None
def set_deadline_preferences(
self, session_id: str, *, enabled: bool, timezone: str, reminder_hour: int
) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_deadline_preferences(
session_id, enabled, timezone, reminder_hour
) VALUES (?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
timezone = excluded.timezone,
reminder_hour = excluded.reminder_hour""",
(session_id, int(enabled), timezone, reminder_hour),
)
def deadline_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, timezone, reminder_hour
FROM push_deadline_preferences WHERE session_id = ?""",
(session_id,),
).fetchone()
return {
"enabled": bool(row[0]) if row else False,
"timezone": row[1] if row else "UTC",
"reminder_hour": row[2] if row else 9,
}
def deadline_reminder_devices(self) -> list[DeadlineReminderDevice]:
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.session_id, s.subscription_json, p.timezone,
p.reminder_hour, p.delivered_local_day
FROM push_subscriptions s
JOIN push_deadline_preferences p ON p.session_id = s.session_id
WHERE p.enabled = 1 ORDER BY s.session_id"""
).fetchall()
return [
DeadlineReminderDevice(row[0], json.loads(row[1]), row[2], row[3], row[4])
for row in rows
]
def mark_deadline_reminder_delivered(self, session_id: str, local_day: str) -> None:
with self._connect() as connection:
connection.execute(
"""UPDATE push_deadline_preferences SET delivered_local_day = ?
WHERE session_id = ? AND enabled = 1""",
(local_day, session_id),
)
def claim_unseen(
self, thread_revisions: Mapping[int, str] | Iterable[int | tuple[int, str]]
) -> list[PushDelivery]:
+29 -10
View File
@@ -273,21 +273,40 @@ class SessionStore:
def managed_status(self, management_id: str, *, idle_timeout_seconds: int) -> str:
"""Return the authorization state for a durable device identifier."""
return self.managed_statuses(
[management_id], idle_timeout_seconds=idle_timeout_seconds
)[management_id]
def managed_statuses(
self, management_ids, *, idle_timeout_seconds: int
) -> dict[str, str]:
"""Return authorization states for durable device identifiers in one read."""
requested = set(management_ids)
if not requested:
return {}
now = int(self.clock())
try:
with self._connect() as connection:
row = connection.execute(
"SELECT expires_at, last_active_at FROM active_sessions "
"WHERE management_id = ?",
(management_id,),
).fetchone()
placeholders = ",".join("?" for _ in requested)
rows = connection.execute(
"SELECT management_id, expires_at, last_active_at "
f"FROM active_sessions WHERE management_id IN ({placeholders})",
tuple(requested),
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None or row[0] <= now:
return "revoked"
if row[1] + max(1, idle_timeout_seconds) <= now:
return "idle"
return "active"
known = {row[0]: row[1:] for row in rows if row[0] in requested}
idle_timeout = max(1, idle_timeout_seconds)
statuses = {}
for management_id in requested:
row = known.get(management_id)
if row is None or row[0] <= now:
statuses[management_id] = "revoked"
elif row[1] + idle_timeout <= now:
statuses[management_id] = "idle"
else:
statuses[management_id] = "active"
return statuses
def revoke_managed(self, management_id: str) -> bool:
try:
+10 -2
View File
@@ -30,10 +30,14 @@ class TodayStore:
self.operation_retention_seconds = operation_retention_seconds
self.recap_limit = recap_limit
self.clock = clock
self._initialize()
def _connect(self) -> sqlite3.Connection:
def _initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout)
if connection.execute("PRAGMA user_version").fetchone()[0] >= 1:
connection.close()
return
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
@@ -101,8 +105,12 @@ class TodayStore:
)
"""
)
connection.execute("PRAGMA user_version = 1")
connection.commit()
return connection
connection.close()
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(self.path, timeout=self.timeout)
def _record_operation(self, connection: sqlite3.Connection, login: str, operation_id: str) -> None:
now = self.clock()
+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-v94" in worker
assert "stackchain-dashboard-shell-v99" in worker
+97
View File
@@ -170,6 +170,102 @@ async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_sign_in_and_records_anomaly(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
options = await client.post("/api/v1/passkeys/authentication/options")
denied = await client.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
events = main._security_event_store().list(limit=10).events
assert denied.status_code == 401
assert "stackchain_session=" not in denied.headers.get("set-cookie", "")
assert [event.kind for event in events] == ["passkey_counter_anomaly"]
assert events[0].device_label == "Phone"
assert events[0].target == "sign_in:dashboard"
@pytest.mark.anyio
async def test_stale_nonzero_passkey_counter_denies_fresh_authorization(
access_control, monkeypatch
):
class StaleAuthentication:
new_sign_count = 4
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: StaleAuthentication(),
)
await asyncio.to_thread(
main._passkey_store().register,
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
options = await client.post(
"/api/v1/passkeys/authorization/options",
json={"action": "merge_pull", "target": "stackchain/dashboard#42"},
headers=headers,
)
denied = await client.post(
"/api/v1/passkeys/authorization/verify",
json={
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"action": "merge_pull",
"target": "stackchain/dashboard#42",
},
headers=headers,
)
events = main._security_event_store().list(limit=10).events
assert options.status_code == 200
assert denied.status_code == 401
assert "grant" not in denied.json()
assert events[0].kind == "passkey_counter_anomaly"
assert events[0].target == "merge_pull:stackchain/dashboard#42"
@pytest.mark.anyio
async def test_passkey_options_are_source_limited_before_challenge_generation(
access_control, monkeypatch
@@ -1950,6 +2046,7 @@ async def test_public_routes_remain_available_and_readiness_hides_identity(acces
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
await main._check_readiness()
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
health = await client.get("/healthz")
+340
View File
@@ -0,0 +1,340 @@
import asyncio
import json
from datetime import datetime, timezone
import pytest
from src import gitea_proxy
from src.push_notifications import PushConfiguration, dispatch_deadline_reminders
from src.push_subscription_store import PushSubscriptionStore
@pytest.mark.anyio
async def test_deadline_reminder_sends_one_private_local_day_digest_and_deduplicates(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("device-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
"device-a", enabled=True, timezone="America/New_York", reminder_hour=9
)
sent = []
async def assigned():
return {
"complete": True,
"items": [
{
"id": 42,
"title": "Private launch plan",
"repository": {"full_name": "private/repo"},
"due_date": "2026-08-14T12:00:00Z",
},
{"id": 43, "due_date": "2026-08-20T12:00:00Z"},
],
}
async def send(_subscription, payload):
sent.append(json.loads(payload))
config = PushConfiguration("public", "private", "mailto:ops@example.com")
now = datetime(2026, 8, 13, 13, 5, tzinfo=timezone.utc)
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 1
assert await dispatch_deadline_reminders(store, config, assigned, send, now=now) == 0
assert sent == [{
"title": "1 deadline needs attention",
"body": "Open Agenda to review or replan it.",
"route": "#/my-work/agenda",
"protect_route": "#/my-work/agenda/protect-today",
"tag": "stackchain-deadline-digest-2026-08-13",
"deadline_count": 1,
}]
assert "Private launch plan" not in json.dumps(sent)
assert "private/repo" not in json.dumps(sent)
@pytest.mark.anyio
async def test_deadline_reminder_counts_calendar_days_per_device_timezone(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for device, timezone_name in (("tokyo", "Asia/Tokyo"), ("la", "America/Los_Angeles")):
store.upsert(device, {
"endpoint": f"https://push.example/{device}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(device, enabled=True, timezone=timezone_name, reminder_hour=0)
async def assigned():
return {"complete": True, "items": [
{"id": 1, "due_date": "2026-08-15T23:59:59Z"},
{"id": 2, "due_date": "2026-08-16T23:59:59Z"},
]}
sent = {}
async def send(subscription, payload):
device = subscription["endpoint"].rsplit("/", 1)[-1]
sent[device] = json.loads(payload)["deadline_count"]
delivered = await dispatch_deadline_reminders(
store, PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned, send, now=datetime(2026, 8, 13, 23, 30, tzinfo=timezone.utc),
)
assert delivered == 2
assert sent == {"tokyo": 2, "la": 1}
@pytest.mark.anyio
async def test_deadline_reminder_fails_closed_for_incomplete_snapshot_and_before_local_hour(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("device-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
)
sent = []
async def incomplete():
return {"complete": False, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
async def complete():
return {"complete": True, "items": [{"id": 42, "due_date": "2026-08-14T12:00:00Z"}]}
async def send(_subscription, payload):
sent.append(payload)
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_deadline_reminders(
store, config, incomplete, send,
now=datetime(2026, 8, 13, 18, 0, tzinfo=timezone.utc),
) == 0
assert await dispatch_deadline_reminders(
store, config, complete, send,
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
) == 0
assert sent == []
@pytest.mark.anyio
async def test_deadline_reminder_skips_snapshot_until_a_device_reaches_its_local_hour(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("device-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
"device-a", enabled=True, timezone="America/Los_Angeles", reminder_hour=9
)
snapshot_calls = 0
async def assigned():
nonlocal snapshot_calls
snapshot_calls += 1
return {"complete": True, "items": []}
delivered = await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned,
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
)
assert delivered == 0
assert snapshot_calls == 0
@pytest.mark.anyio
async def test_deadline_reminder_skips_snapshot_after_every_device_was_delivered_today(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id, timezone_name in (
("device-a", "UTC"),
("device-b", "America/New_York"),
):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
session_id, enabled=True, timezone=timezone_name, reminder_hour=9
)
store.mark_deadline_reminder_delivered(session_id, "2026-08-13")
snapshot_calls = 0
async def assigned():
nonlocal snapshot_calls
snapshot_calls += 1
return {"complete": True, "items": []}
delivered = await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned,
now=datetime(2026, 8, 13, 15, 0, tzinfo=timezone.utc),
)
assert delivered == 0
assert snapshot_calls == 0
def test_deadline_preferences_persist_on_the_existing_device_subscription(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("device-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
"device-a", enabled=True, timezone="Europe/London", reminder_hour=8
)
assert store.deadline_preferences("device-a") == {
"enabled": True,
"timezone": "Europe/London",
"reminder_hour": 8,
}
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
pages = {
1: {"items": [{"id": 1}], "has_more": True},
2: {"items": [{"id": 2}], "has_more": False},
}
async def work_page(stream, page, limit):
assert stream == "issue"
assert limit == 50
return pages[page]
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
assert await gitea_proxy.assigned_issue_snapshot() == {
"items": [{"id": 1}, {"id": 2}],
"complete": True,
}
@pytest.mark.anyio
async def test_competing_workers_send_one_deadline_digest(tmp_path):
path = tmp_path / "push.sqlite3"
first = PushSubscriptionStore(path)
second = PushSubscriptionStore(path)
first.upsert("device-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
first.set_deadline_preferences(
"device-a", enabled=True, timezone="UTC", reminder_hour=9
)
sending = __import__("asyncio").Event()
release = __import__("asyncio").Event()
sent = []
async def assigned():
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
async def send(_subscription, payload):
sent.append(payload)
sending.set()
await release.wait()
config = PushConfiguration("public", "private", "mailto:ops@example.com")
now = datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc)
active = __import__("asyncio").create_task(
dispatch_deadline_reminders(first, config, assigned, send, now=now)
)
await sending.wait()
competing = await dispatch_deadline_reminders(second, config, assigned, send, now=now)
release.set()
assert competing == 0
assert await active == 1
assert len(sent) == 1
@pytest.mark.anyio
async def test_deadline_reminders_bound_fanout_and_isolate_failed_devices(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for index in range(4):
session_id = f"device-{index}"
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
session_id, enabled=True, timezone="UTC", reminder_hour=9
)
active = 0
peak = 0
two_active = asyncio.Event()
async def assigned():
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
async def send(subscription, _payload):
nonlocal active, peak
active += 1
peak = max(peak, active)
if active == 2:
two_active.set()
await asyncio.wait_for(two_active.wait(), timeout=0.5)
active -= 1
if subscription["endpoint"].endswith("device-1"):
raise RuntimeError("provider failed")
delivered = await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned,
send,
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
max_concurrency=2,
)
assert delivered == 3
assert peak == 2
@pytest.mark.anyio
async def test_deadline_reminders_bulk_authorize_due_devices_and_prune_inactive(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("active-device", "revoked-device"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
session_id, enabled=True, timezone="UTC", reminder_hour=9
)
authorization_calls = []
sent = []
async def assigned():
return {"complete": True, "items": [{"id": 1, "due_date": "2026-08-14T00:00:00Z"}]}
async def session_statuses(management_ids):
authorization_calls.append(list(management_ids))
return {
management_id: "active" if management_id == "active-device" else "revoked"
for management_id in management_ids
}
async def send(subscription, _payload):
sent.append(subscription["endpoint"])
delivered = await dispatch_deadline_reminders(
store,
PushConfiguration("public", "private", "mailto:ops@example.com"),
assigned,
send,
now=datetime(2026, 8, 13, 10, 0, tzinfo=timezone.utc),
session_statuses=session_statuses,
)
assert delivered == 1
assert authorization_calls == [["active-device", "revoked-device"]]
assert sent == ["https://push.example/active-device"]
assert store.is_subscribed("revoked-device") is False
+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
+2 -2
View File
@@ -69,7 +69,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 95 * 1024
assert len(first.runtime_gzip_bytes) <= 96 * 1024
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
@@ -187,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
worker = changed_frontend / "service-worker.js"
worker.write_text(
worker.read_text().replace(
"const CACHE = 'stackchain-dashboard-shell-v94';",
"const CACHE = 'stackchain-dashboard-shell-v99';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+6
View File
@@ -1,4 +1,5 @@
import asyncio
from unittest.mock import AsyncMock
import httpx
import pytest
@@ -245,6 +246,7 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
calls = []
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
@@ -270,6 +272,7 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
calls.append("snapshot cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
assert main._live_snapshot_task is not None
@@ -306,6 +309,7 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
calls.append("available scan cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
task = main._available_issue_snapshot_task
@@ -341,6 +345,7 @@ async def test_application_shutdown_drains_authored_mutation_before_transport(mo
return {"id": 461}
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
@@ -382,6 +387,7 @@ async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
+190
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
@@ -240,6 +354,82 @@ async def test_available_issue_endpoint_reuses_catalog_published_by_another_work
}
@pytest.mark.anyio
async def test_available_issue_endpoint_waits_for_slow_cold_catalog_from_another_worker(
monkeypatch, tmp_path
):
now = 100.0
path = tmp_path / "shared-available.sqlite3"
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
owner = owner_store.try_acquire_refresh(lease_seconds=6)
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
sleep_calls = 0
async def publish_after_existing_one_second_window(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 60:
owner_store.publish(
owner,
items=[{"repository": "stackchain/api", "number": 8}],
)
async def must_not_scan():
raise AssertionError("a valid cross-worker lease must prevent a duplicate scan")
monkeypatch.setattr(main.asyncio, "sleep", publish_after_existing_one_second_window)
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?page=1")
assert sleep_calls > 50
assert response.status_code == 200
assert response.json()["items"] == [
{"repository": "stackchain/api", "number": 8}
]
@pytest.mark.anyio
async def test_available_issue_endpoint_takes_over_released_cold_refresh(
monkeypatch, tmp_path
):
now = 100.0
path = tmp_path / "shared-available.sqlite3"
owner_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
worker_store = AvailableIssueSnapshotStore(path, clock=lambda: now)
owner = owner_store.try_acquire_refresh(lease_seconds=6)
monkeypatch.setattr(main, "_available_issue_snapshot_store", worker_store)
sleep_calls = 0
scan_calls = 0
async def release_failed_owner(_delay):
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls == 3:
owner_store.release_refresh(owner)
async def available():
nonlocal scan_calls
scan_calls += 1
return [{"repository": "stackchain/web", "number": 9}]
monkeypatch.setattr(main.asyncio, "sleep", release_failed_owner)
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?page=1")
assert response.status_code == 200
assert response.json()["items"] == [
{"repository": "stackchain/web", "number": 9}
]
assert scan_calls == 1
@pytest.mark.anyio
async def test_available_issue_endpoint_coalesces_cold_scan_and_reuses_it_for_pages(monkeypatch):
calls = 0
+66
View File
@@ -11,6 +11,49 @@ def test_health_endpoint_reports_service_liveness_without_gitea_access():
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_readiness_endpoint_never_contacts_gitea_for_a_request(monkeypatch):
calls = 0
async def unexpected_user_lookup():
nonlocal calls
calls += 1
raise AssertionError("request-time readiness must not contact Gitea")
monkeypatch.setattr(main, "_readiness_state", None, raising=False)
monkeypatch.setattr(main, "current_user", unexpected_user_lookup)
first = await main.readiness()
second = await main.readiness()
assert first.status_code == 503
assert second.status_code == 503
assert calls == 0
@pytest.mark.anyio
async def test_readiness_monitor_refreshes_on_an_interval_and_cancels(monkeypatch):
calls = 0
refreshed_twice = asyncio.Event()
async def check():
nonlocal calls
calls += 1
if calls == 2:
refreshed_twice.set()
monkeypatch.setattr(main, "_check_readiness", check)
monkeypatch.setattr(main, "READINESS_INTERVAL_SECONDS", 0.01, raising=False)
task = asyncio.create_task(main._readiness_monitor())
await asyncio.wait_for(refreshed_twice.wait(), timeout=0.2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert calls == 2
@pytest.mark.anyio
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
async def connected_user():
@@ -18,6 +61,8 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
monkeypatch.setattr(main, "current_user", connected_user)
await main._check_readiness()
response = await main.readiness()
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
@@ -35,6 +80,8 @@ async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkey
monkeypatch.setattr(main, "current_user", unavailable_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -53,6 +100,8 @@ async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monk
monkeypatch.setattr(main, "current_user", null_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -77,6 +126,8 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "current_user", hanging_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -87,3 +138,18 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
"error": "Gitea readiness check timed out after 0.01s",
}
assert cancelled.is_set()
@pytest.mark.anyio
async def test_readiness_endpoint_expires_a_stale_healthy_snapshot(monkeypatch):
monkeypatch.setattr(
main,
"_readiness_state",
main.ReadinessState(checked_at=10.0, ready=True, login="timmy"),
)
monkeypatch.setattr(main.time, "monotonic", lambda: 76.0)
response = await main.readiness()
assert response.status_code == 503
assert json.loads(response.body)["error"] == "Gitea readiness status is not available"
+17
View File
@@ -65,6 +65,23 @@ def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path
) == {"revision": 2, "records": {}}
def test_initialized_later_reads_remain_available_during_a_planning_write(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path, timeout=0.05)
expected = store.apply(
"timmy", "seed", "defer", "issue:r:1:",
wake_at="2026-08-10T09:00:00.000Z",
)
writer = sqlite3.connect(path)
writer.execute("BEGIN IMMEDIATE")
try:
assert LaterStore(path, timeout=0.05).get("timmy") == expected
finally:
writer.rollback()
writer.close()
def test_batch_applies_in_one_ordered_idempotent_unit(tmp_path):
store = LaterStore(tmp_path / "later.sqlite3")
operations = [
+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-v94" in source
assert "stackchain-dashboard-shell-v99" 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-v94" in worker
assert "stackchain-dashboard-shell-v99" in worker
+119
View File
@@ -0,0 +1,119 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
ROOT = Path(__file__).resolve().parents[1]
REPLAN = ROOT / "frontend" / "agenda-replan.js"
def run_node(script):
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_overdue_replan_sweep_orders_items_and_only_advances_after_confirmed_change():
script = f"""
const createSweep = require({json.dumps(str(REPLAN))});
const calls = [];
let fail = true;
const items = [
{{key:'o/r#3', repository:'o/r', number:3, due_date:'2026-08-11T18:00:00Z'}},
{{key:'o/r#1', repository:'o/r', number:1, due_date:'2026-08-09T18:00:00Z'}},
{{key:'o/r#2', repository:'o/r', number:2, due_date:'2026-08-10T18:00:00Z'}},
];
const sweep = createSweep({{
now: () => new Date('2026-08-12T12:00:00'),
update: async (item, due) => {{ calls.push([item.key, due]); if (fail) throw new Error('offline'); return {{due_date:due}}; }},
}});
async function run() {{
const started = sweep.start(items);
const failed = await sweep.tomorrow();
const afterFailure = sweep.snapshot();
fail = false;
const changed = await sweep.tomorrow();
const afterChange = sweep.snapshot();
const kept = await sweep.keep();
process.stdout.write(JSON.stringify({{started, failed, afterFailure, changed, afterChange, kept, calls}}));
}}
run();
"""
assert run_node(script) == {
"started": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
"failed": {"ok": False, "error": "offline"},
"afterFailure": {"active": True, "index": 0, "total": 3, "current": "o/r#1", "pending": False},
"changed": {"ok": True, "done": False},
"afterChange": {"active": True, "index": 1, "total": 3, "current": "o/r#2", "pending": False},
"kept": {"ok": True, "done": False},
"calls": [["o/r#1", "2026-08-13T23:59:59Z"], ["o/r#1", "2026-08-13T23:59:59Z"]],
}
def test_overdue_replan_rejects_non_future_choice_and_prevents_parallel_updates():
script = f"""
const createSweep = require({json.dumps(str(REPLAN))});
let resolveUpdate;
let calls = 0;
const sweep = createSweep({{
now: () => new Date('2026-08-12T12:00:00'),
update: async () => {{ calls += 1; await new Promise(resolve => resolveUpdate = resolve); return {{}}; }},
}});
async function run() {{
sweep.start([{{key:'o/r#1', due_date:'2026-08-10T18:00:00Z'}}]);
const invalid = await sweep.choose('2026-08-12');
const first = sweep.choose('2026-08-14');
const duplicate = await sweep.choose('2026-08-15');
resolveUpdate();
const confirmed = await first;
process.stdout.write(JSON.stringify({{invalid, duplicate, confirmed, calls, final:sweep.snapshot()}}));
}}
run();
"""
assert run_node(script) == {
"invalid": {"ok": False, "error": "Choose a future date."},
"duplicate": {"ok": False, "error": "Deadline update already in progress."},
"confirmed": {"ok": True, "done": True},
"calls": 1,
"final": {"active": False, "index": 1, "total": 1, "current": None, "pending": False},
}
def test_overdue_replan_orders_by_calendar_day_without_timezone_drift():
script = f"""
const createSweep = require({json.dumps(str(REPLAN))});
const sweep = createSweep({{update:async()=>({{}})}});
const started = sweep.start([
{{key:'o/r#2',repository:'o/r',number:2,due_date:'2026-08-13T01:00:00Z'}},
{{key:'o/r#1',repository:'o/r',number:1,due_date:'2026-08-12T23:59:59Z'}},
]);
process.stdout.write(JSON.stringify(started));
"""
environment = {**__import__('os').environ, "TZ": "Asia/Tokyo"}
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True, env=environment
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout)["current"] == "o/r#1"
@pytest.mark.anyio
async def test_mobile_agenda_exposes_thumb_safe_replan_controls_and_wires_existing_mutation():
html = await dashboard()
assert 'id="start-agenda-replan"' in html
assert 'id="agenda-replan-controls"' in html
assert 'id="agenda-replan-progress"' in html
assert 'id="agenda-replan-keep"' in html
assert 'id="agenda-replan-tomorrow"' in html
assert 'id="agenda-replan-date"' in html
assert 'id="agenda-replan-choose"' in html
assert 'id="agenda-replan-cancel"' in html
assert "agendaReplan.start(overdue)" in html
assert "issueController.updateDueDate(item, dueDate)" in html
assert ".agenda-replan-actions button { min-height:44px;" in html
assert "@media(max-width:360px)" in html
+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-v94" in worker
assert "stackchain-dashboard-shell-v99" in worker
def test_all_conversation_composers_offer_accessible_mobile_mentions():
+33 -5
View File
@@ -15,16 +15,18 @@ class FakeTarget {
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 state = {installCalls:0, offlineCalls:0, pushCalls:0, deadlineCalls: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 deadlineButton = new FakeTarget();
const installStatus = new FakeTarget();
const offlineStatus = new FakeTarget();
const pushStatus = new FakeTarget();
const deadlineStatus = new FakeTarget();
const readyStatus = new FakeTarget();
const escapeTarget = new FakeTarget();
const promptCard = new FakeTarget(); promptCard.hidden = true;
@@ -41,16 +43,18 @@ let readiness = {
install:{state:'complete', detail:'Stackchain is installed.'},
offline:{state:'incomplete', detail:'Offline work is off.'},
push:{state:'unavailable', detail:'Notifications are unavailable.'},
deadline:{state:'unavailable', detail:'Deadline reminders are unavailable.'},
};
const setup = createMobileDeviceSetup({
launcher, closeButton, sheet, installButton, offlineButton, pushButton,
installStatus, offlineStatus, pushStatus, readyStatus, escapeTarget,
launcher, closeButton, sheet, installButton, offlineButton, pushButton, deadlineButton,
installStatus, offlineStatus, pushStatus, deadlineStatus, 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; },
enableDeadline:async () => { state.deadlineCalls += 1; },
});
(async () => { await setup.start(); __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); });
""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script)
@@ -166,6 +170,29 @@ process.stdout.write(JSON.stringify({
assert result == {"hidden": True, "launcherFocused": True}
def test_deadline_step_runs_one_setup_action_and_uses_confirmed_readiness():
result = run_scenario("""
readiness.push = {state:'complete', detail:'New update notifications are enabled.'};
readiness.deadline = {state:'incomplete', detail:'Choose when to receive deadline reminders.'};
await launcher.dispatch('click', {currentTarget:launcher});
readiness.deadline = {state:'complete', detail:'Deadline reminders enabled for 08:00 local time.'};
await deadlineButton.dispatch('click');
process.stdout.write(JSON.stringify({
calls:state.deadlineCalls,
detail:deadlineStatus.textContent,
hidden:deadlineButton.hidden,
summary:readyStatus.textContent,
}));
""")
assert result == {
"calls": 1,
"detail": "Deadline reminders enabled for 08:00 local time.",
"hidden": True,
"summary": "3 of 4 available steps ready.",
}
def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
@@ -180,13 +207,14 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
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 all(f'id="device-setup-{step}"' in html for step in ("install", "offline", "push", "deadline"))
assert 'id="device-setup-deadline-hour"' in html
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-v94" in worker
assert "stackchain-dashboard-shell-v99" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+9 -6
View File
@@ -14,10 +14,11 @@ def test_mobile_launch_selects_priority_queue_without_overriding_saved_choice():
script = f"""
const mobileLaunch = require({json.dumps(str(MOBILE_LAUNCH))});
process.stdout.write(JSON.stringify({{
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4}}),
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4}}),
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0}}),
saved: mobileLaunch.chooseFilter({{saved:'review', today:2, attention:4}}),
today: mobileLaunch.chooseFilter({{saved:null, today:2, attention:4, agenda:6}}),
attention: mobileLaunch.chooseFilter({{saved:null, today:0, attention:4, agenda:6}}),
agenda: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:6}}),
all: mobileLaunch.chooseFilter({{saved:null, today:0, attention:0, agenda:0}}),
saved: mobileLaunch.chooseFilter({{saved:'agenda', today:2, attention:4, agenda:6}}),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
@@ -26,8 +27,9 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"today": "today",
"attention": "attention",
"agenda": "agenda",
"all": "all",
"saved": "review",
"saved": "agenda",
}
@@ -64,9 +66,10 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
assert "mobileLaunch.chooseFilter" in html
assert "agenda: counts.agenda" in html
assert "mobileLaunch.createDisclosure" in html
assert "@media (max-width: 600px)" in html
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
+134 -21
View File
@@ -9,10 +9,11 @@ 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_plan_then_fallback():
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_find():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2, eligible:3}};
@@ -26,7 +27,7 @@ const entry = createEntry({{
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
planToday: () => calls.push('plan'),
openFallback: () => calls.push('fallback'),
findWork: () => calls.push('find'),
}});
const modes = [];
modes.push(entry.open());
@@ -42,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", "plan", "work"],
"calls": ["continue", "resume", "start", "plan", "fallback"],
"modes": ["continue", "resume", "start", "plan", "find"],
"calls": ["continue", "resume", "start", "plan", "find"],
}
@@ -164,10 +165,10 @@ 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);
dock.updateWork('find', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting, planning,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
finding:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
@@ -183,7 +184,7 @@ process.stdout.write(JSON.stringify({{
"resuming": {"text": "Resume", "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"planning": {"text": "Plan", "label": "Plan Today"},
"fallback": {"text": "Work", "label": "Work"},
"finding": {"text": "Find", "label": "Find work"},
}
@@ -292,26 +293,40 @@ sheet.showModal = function () {{ this.open = true; }};
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
const deadline = new FakeElement();
const rows = Object.fromEntries(['today','agenda','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
const selected = [];
const utilities = [];
const dock = createDock({{
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
onSelectQueue:name=>selected.push(name),
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge, deadlineBadge:deadline,
onSelectQueue:(name,row)=>name === 'recaps' ? utilities.push(row === rows.recaps ? 'recaps:trigger' : 'recaps:missing-trigger') : selected.push(name),
observe() {{}},
}});
dock.start();
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
dock.updateQueues({{today:2, agenda:5, attention:1, update:5, later:3, draft:4}});
queues.click();
const opened = sheet.open;
rows.later.click();
rows.update.click();
const closedAfterSelect = !sheet.open;
queues.click(); rows.recaps.click();
const closedAfterUtility = !sheet.open;
queues.click(); close.click();
const populated = {{
deadline:deadline.textContent, deadlineHidden:deadline.hidden,
agendaDue:rows.agenda.attributes['data-deadlines'] || null,
badgeLabel:queues.attributes['aria-label'],
}};
dock.updateQueues({{today:0, agenda:0, attention:0, update:0, later:0, draft:0}});
process.stdout.write(JSON.stringify({{
opened, closedAfterSelect, selected,
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
opened, closedAfterSelect, closedAfterUtility, selected, utilities,
badge:badge.textContent, populated,
clearedDeadlineHidden:deadline.hidden,
clearedAgendaDue:rows.agenda.attributes['data-deadlines'] || null,
clearedBadgeLabel:queues.attributes['aria-label'],
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
updateLabel:rows.update.attributes['aria-label'],
queueFocuses:queues.focuses,
columnState:nav.attributes['data-attention'] || null,
}}));
@@ -322,15 +337,96 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"opened": True,
"closedAfterSelect": True,
"selected": ["later"],
"badge": "10",
"badgeLabel": "Queues, 10 items",
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
"queueFocuses": 2,
"closedAfterUtility": True,
"selected": ["update"],
"utilities": ["recaps:trigger"],
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
"badge": "0",
"populated": {"badgeLabel": "Queues, 10 items, 5 upcoming deadlines", "deadline": "5 due", "deadlineHidden": False, "agendaDue": "true"},
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues, 0 items",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 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, agenda: 5, 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, agenda: 5, later: 4, draft: 3}};
const agenda = launcher.recommend();
const opened = launcher.continueWork();
counts = {{attention: 0, today: 0, agenda: 0, later: 0, draft: 0}};
const fallback = launcher.recommend();
launcher.continueWork();
process.stdout.write(JSON.stringify({{first, agenda, 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)"},
"agenda": {"name": "agenda", "count": 5, "label": "Open Agenda (5)"},
"opened": "opened",
"fallback": {"name": "find", "count": 0, "label": "Find Work"},
"calls": ["filter:agenda", "open:agenda", "find"],
}
@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()
@@ -339,14 +435,31 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
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="agenda"' in html
assert '<strong>Agenda</strong><small>Upcoming deadlines</small>' in html
assert 'data-work-filter="agenda"' in html
assert "const agendaItems = agendaMyWork(activeMyWork);" in html
assert "counts.agenda = agendaItems.length;" in html
assert 'data-agenda-group' 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 'id="mobile-deadline-count"' in html
assert 'mobileTaskDock.updateQueues(counts)' in html
assert "onSelectQueue: openMobileQueue" in html
assert '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
assert "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-deadline' in html
assert '[data-deadlines="true"]' in html
assert '.mobile-task-dock[data-attention="true"]' not in html
@@ -389,7 +502,7 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
assert '<script src="static/mobile-task-dock.js"></script>' in html
assert "createMobileTaskDock({" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "onSelectQueue: openMobileQueue" in html
assert "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
+581 -3
View File
@@ -8,7 +8,24 @@ import pytest
from tests.dashboard_bundle import dashboard
@pytest.mark.anyio
async def test_empty_all_work_queue_offers_find_and_create_actions():
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
source = await dashboard()
assert 'id="empty-work-start"' in markup
assert 'Ready for something new?' in markup
assert 'id="empty-work-find"' in markup
assert 'id="empty-work-create"' in markup
assert "qs('#empty-work-find').addEventListener('click'" in source
assert "qs('#find-work').click()" in source
assert "qs('#empty-work-create').addEventListener('click'" in source
assert "qs('#new-issue').click()" in source
assert "emptyWorkStart.hidden = !showEmptyStart" in source
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
@@ -23,7 +40,309 @@ COMMENT_ACTIONS = Path(__file__).parents[1] / "frontend" / "comment-actions.js"
PICK_WORK = Path(__file__).parents[1] / "frontend" / "pick-work.js"
WORK_ROUTE = Path(__file__).parents[1] / "frontend" / "work-route.js"
UPDATE_OWNERSHIP = Path(__file__).parents[1] / "frontend" / "update-ownership.js"
def test_protect_today_route_is_an_explicit_agenda_action():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
process.stdout.write(JSON.stringify(routes.parse('#/my-work/agenda/protect-today')));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"kind": "queue", "filter": "agenda", "action": "protect-today"
}
CARD_PLANNING = Path(__file__).parents[1] / "frontend" / "card-planning.js"
TODAY_WORK = Path(__file__).parents[1] / "frontend" / "today-work.js"
WORK_SELECTION = Path(__file__).parents[1] / "frontend" / "work-selection.js"
def test_work_selection_selects_matching_items_in_order_with_an_explicit_cap():
script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection({{limit:3}});
selection.start();
selection.select({{kind:'issue', repository:'stackchain/api', number:1}});
const result = selection.selectMany([
{{kind:'issue', repository:'stackchain/api', number:1}},
{{kind:'pull', repository:'stackchain/web', number:2}},
{{kind:'issue', repository:'stackchain/app', number:3}},
{{kind:'issue', repository:'stackchain/overflow', number:4}},
]);
process.stdout.write(JSON.stringify({{result, snapshot:selection.snapshot()}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
output = json.loads(result.stdout)
assert output == {
"result": {"status": "limit", "added": 2, "count": 3, "limit": 3},
"snapshot": {
"active": True,
"count": 3,
"ids": [
"issue:stackchain/api:1:",
"pull:stackchain/web:2:",
"issue:stackchain/app:3:",
],
},
}
def test_work_selection_clears_matches_without_leaving_selection_mode():
script = f"""
const createWorkSelection = require({json.dumps(str(WORK_SELECTION))});
const selection = createWorkSelection();
selection.start();
selection.selectMany([
{{kind:'issue', repository:'stackchain/api', number:1}},
{{kind:'pull', repository:'stackchain/web', number:2}},
]);
process.stdout.write(JSON.stringify(selection.clear()));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {"active": True, "count": 0, "ids": []}
@pytest.mark.anyio
async def test_mobile_batch_planning_can_select_and_clear_active_queue_matches():
html = await dashboard()
assert 'id="select-matching-work"' in html
assert 'id="clear-work-selection"' in html
assert "workSelection.selectMany(visible)" in html
assert "workSelection.clear()" in html
assert "matches selected" in html
assert ".selection-scope-actions button { min-height:44px;" in html
assert "max-width:100%" in html
@pytest.mark.anyio
async def test_issue_sheet_formats_due_date_as_a_calendar_day_without_local_timestamp_conversion():
source = await dashboard()
assert "formatCalendarDueDate(detail.due_date)" in source
assert "new Date(detail.due_date).toLocaleDateString()" not in source
def test_queue_finder_matches_repository_number_and_title_without_reordering():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
{{repository:'stackchain/api', key:'stackchain/api#42', number:42, title:'Retry failed deploy'}},
{{repository:'stackchain/web', key:'stackchain/web#7', number:7, title:'Polish mobile queue'}},
{{repository:'other/repo', key:'other/repo#42', number:42, title:'Unrelated task'}},
];
process.stdout.write(JSON.stringify({{
repo:work.findQueueItems(items, 'STACKCHAIN/API').map(item => item.key),
number:work.findQueueItems(items, '#42').map(item => item.key),
title:work.findQueueItems(items, 'mobile queue').map(item => item.key),
clear:work.findQueueItems(items, ' ').map(item => item.key),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"repo": ["stackchain/api#42"],
"number": ["stackchain/api#42", "other/repo#42"],
"title": ["stackchain/web#7"],
"clear": ["stackchain/api#42", "stackchain/web#7", "other/repo#42"],
}
def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const now = new Date('2026-08-12T12:00:00');
const items = [
{{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}},
{{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}},
{{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}},
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}},
{{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}},
{{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}},
{{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}},
{{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group])));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["o/r#5", "Overdue"], ["o/r#3", "Today"],
["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"],
]
def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
{{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key)));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
@pytest.mark.parametrize("timezone_name", ["Asia/Tokyo", "America/Los_Angeles"])
def test_mobile_agenda_preserves_the_gitea_calendar_day_in_every_timezone(timezone_name):
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const now = new Date(2026, 7, 13, 12, 0, 0);
const built = work({{
user:{{login:'timmy'}}, notifications:[], pull_requests:[],
issues:[{{number:1,title:'Ship',repository:'o/r',labels:[],assignees:['timmy'],due_date:'2026-08-13T23:59:59Z'}}],
}}, now);
process.stdout.write(JSON.stringify({{
dueLabel:built[0].due_label,
agenda:work.agendaMyWork(built, now).map(item => [item.key, item.agenda_group]),
}}));
"""
environment = {**os.environ, "TZ": timezone_name}
result = subprocess.run(
["node", "-e", script], capture_output=True, text=True, env=environment
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"dueLabel": "Due today",
"agenda": [["o/r#1", "Today"]],
}
def test_agenda_pager_loads_every_issue_page_single_flight_and_retries_failed_page():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const calls = [];
let failPage = 3;
let items = [{{id:1, title:'first'}}];
let pagination = {{issue:{{page:1,total:4,has_more:true}}}};
const pager = work.createWorkPager({{
load: async (stream, page) => {{
calls.push(page);
await new Promise(resolve => setTimeout(resolve, 5));
if (page === failPage) throw new Error('offline');
return {{page,total:4,has_more:page < 4,items:[{{id:page,title:'page '+page}}]}};
}},
onItems: (_stream, next) => {{ items = next; }},
onPagination: next => {{ pagination = next; }},
onStatus: () => {{}},
}});
pager.reset(pagination);
async function run() {{
const first = pager.loadAll('issue', () => items);
const duplicate = pager.loadAll('issue', () => items);
const failed = await first;
const samePromise = first === duplicate;
failPage = 0;
const retried = await pager.loadAll('issue', () => items);
process.stdout.write(JSON.stringify({{
failed, samePromise, retried, calls, ids:items.map(item => item.id), pagination,
}}));
}}
run();
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"failed": False,
"samePromise": True,
"retried": True,
"calls": [2, 3, 3, 4],
"ids": [1, 2, 3, 4],
"pagination": {"issue": {"page": 4, "total": 4, "has_more": False}},
}
@pytest.mark.anyio
async def test_agenda_activation_checks_all_issue_pages_before_showing_empty_state():
html = await dashboard()
assert "workPager.loadAll('issue'" in html
assert "Checking all assigned deadlines…" in html
assert "Agenda check paused. Retry to check older assigned deadlines." in html
assert "selectedWorkFilter === 'agenda' && workPagination.issue?.has_more" in html
@pytest.mark.anyio
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
html = await dashboard()
assert '<form class="queue-finder" id="queue-finder" role="search">' in html
assert 'id="queue-find-input"' in html
assert 'aria-label="Find in active work queue"' in html
assert 'id="clear-queue-find"' in html
assert 'id="search-older-work"' in html
assert 'id="queue-find-status" role="status" aria-live="polite"' in html
assert '.queue-finder input, .queue-finder button { min-height:44px;' in html
mobile = html.index('@media (max-width: 600px)')
assert '.queue-finder { position:sticky;' in html[mobile:]
assert "findQueueItems(queueItems, queueFindQuery)" in html
def test_today_batch_admission_is_ordered_deduplicated_and_atomic():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
function storage(fail = false) {{
const values = new Map();
return {{
getItem:key => values.get(key) || null,
setItem(key, value) {{ if (fail) throw new Error('quota'); values.set(key, value); }},
removeItem:key => values.delete(key),
}};
}}
const first = {{kind:'issue',repository:'o/r',number:1}};
const second = {{kind:'pull',repository:'o/r',number:2}};
const third = {{kind:'issue',repository:'o/r',number:3}};
const today = createTodayWork({{storage:storage(),getLogin:()=> 'timmy',limit:3}});
const admitted = today.addMany([first, first, second]);
const existing = today.addMany([second, third]);
const full = today.addMany([{{kind:'issue',repository:'o/r',number:4}}]);
const broken = createTodayWork({{storage:storage(true),getLogin:()=> 'timmy'}});
process.stdout.write(JSON.stringify({{
admitted, existing, full, ids:today.read(), broken:broken.addMany([first, second]), brokenIds:broken.read(),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"admitted": {"status": "added", "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
"existing": {"status": "added", "ids": ["issue:o/r:3:"]},
"full": {"status": "full", "ids": []},
"ids": ["issue:o/r:1:", "pull:o/r:2:", "issue:o/r:3:"],
"broken": {"status": "unavailable", "ids": []},
"brokenIds": [],
}
def test_work_selection_tracks_cross_kind_items_and_retains_failures():
script = f"""
const create = require({json.dumps(str(WORK_SELECTION))});
const selection = create({{limit:3}});
const issue = {{kind:'issue',repository:'o/r',number:1,title:'One'}};
const pull = {{kind:'pull',repository:'o/r',number:2,title:'Two'}};
selection.start(); selection.select(issue); selection.select(pull);
const before = selection.snapshot();
selection.retain([pull]);
process.stdout.write(JSON.stringify({{before, after:selection.snapshot(), issueId:selection.identity(issue)}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"active": True, "count": 2, "ids": ["issue:o/r:1:", "pull:o/r:2:"]},
"after": {"active": True, "count": 1, "ids": ["pull:o/r:2:"]},
"issueId": "issue:o/r:1:",
}
def test_card_planning_disclosures_keep_one_open_and_escape_restores_focus():
@@ -395,6 +714,118 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash}}));
assert output["hash"] == "#/my-work/review/stackchain/dashboard/10"
def test_updates_inbox_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const listeners = {{}};
const location = {{hash:'#/my-work/updates'}};
const calls = [];
const stack = ['#/my-work/updates'];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: queue => calls.push(['queue', queue]),
onOpen: item => calls.push(['open', item.notification_id]),
onClose: () => calls.push(['close']),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([]);
controller.open({{kind:'update', notification_id:42}});
controller.close();
process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.parse(location.hash)}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"calls": [
["queue", "update"],
["open", 42],
["close"],
["queue", "update"],
],
"hash": "#/my-work/updates",
"parsed": {"kind": "queue", "filter": "update"},
}
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'agenda', 'attention', 'updates', 'later', 'drafts'];
const parsed = canonical.map(name => [name, routes.parse('#/my-work/' + name)]);
const listeners = {{}};
const location = {{hash:'#/my-work/agenda'}};
const calls = [];
const stack = ['#/my-work/agenda'];
let cursor = 0;
const history = {{
pushState(state, _, hash) {{ stack.splice(cursor + 1); stack.push(hash); cursor += 1; location.hash = hash; }},
replaceState(state, _, hash) {{ stack[cursor] = hash; location.hash = hash; }},
back() {{ cursor -= 1; location.hash = stack[cursor]; listeners.popstate(); }},
}};
const controller = routes.createController({{
location, history,
eventTarget: {{addEventListener(name, fn) {{ listeners[name] = fn; }}}},
onQueue: queue => calls.push(['queue', queue]),
onOpen: item => calls.push(['open', item.number]),
onClose: () => calls.push(['close']),
onInvalid: () => calls.push(['invalid']),
}});
controller.start();
controller.setItems([{{kind:'issue', repository:'stackchain/dashboard', number:42}}]);
controller.open({{kind:'issue', repository:'stackchain/dashboard', number:42}});
controller.close();
controller.queue('draft');
process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"parsed": [
["today", {"kind": "queue", "filter": "today"}],
["agenda", {"kind": "queue", "filter": "agenda"}],
["attention", {"kind": "queue", "filter": "attention"}],
["updates", {"kind": "queue", "filter": "update"}],
["later", {"kind": "queue", "filter": "later"}],
["drafts", {"kind": "queue", "filter": "draft"}],
],
"calls": [
["queue", "agenda"],
["open", 42],
["close"],
["queue", "agenda"],
["queue", "draft"],
],
"hash": "#/my-work/drafts",
}
def test_mobile_queue_routes_reject_unknown_or_nested_fragments():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
process.stdout.write(JSON.stringify([
routes.parse('#/my-work/tomorrow'),
routes.parse('#/my-work/today/extra'),
routes.parse('#/my-work/drafts/1'),
]));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == [None, None, None]
def test_work_route_controller_resolves_cold_routes_without_erasing_the_fragment():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
@@ -507,6 +938,10 @@ async def test_dashboard_wires_addressable_work_sheets_back_navigation_and_share
assert html.count('class="share-work-route"') == 4
assert 'createWorkRoute.share(window.location.href, navigator, navigator.clipboard)' in html
assert 'Route unavailable · this item is no longer in My Work.' in html
assert "onQueue: openWorkQueueRoute" in html
assert "selectWorkQueue(filter, { preserveRoute:true })" in html
assert "workRoute.queue(filter)" in html
assert "openDeliveryReceiptRoute" not in html
def test_my_work_queue_prioritizes_labels_then_reviews_and_keeps_repo_identity():
@@ -1227,6 +1662,59 @@ process.stdout.write(JSON.stringify({{noIdentity, invalid, failedWrite}}));
}
def test_later_queue_defers_a_batch_atomically_with_one_wake_time_and_change_per_item():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
const values = new Map();
const changes = [];
let writes = 0;
let fail = false;
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key,value) => {{ writes += 1; if (fail) throw new Error('quota'); values.set(key,value); }},
removeItem:key => values.delete(key),
}};
const first = {{kind:'update',repository:'stackchain/api',number:17,notification_id:91}};
const second = {{kind:'update',repository:'stackchain/web',number:8,notification_id:92}};
const store = createLaterWork({{
storage, getLogin:() => 'timmy', now:() => new Date('2026-08-08T12:00:00Z'),
setTimer:() => 1, clearTimer:() => {{}},
onChange:(...change) => changes.push(change),
}});
const deferred = store.deferMany([first, second], new Date('2026-08-09T09:00:00Z'));
const saved = store.partition([first, second]);
const beforeFailure = JSON.stringify(store.read());
fail = true;
const unavailable = store.deferMany([
{{kind:'update',repository:'stackchain/api',number:19,notification_id:93}},
{{kind:'update',repository:'stackchain/api',number:20,notification_id:94}},
], new Date('2026-08-10T09:00:00Z'));
process.stdout.write(JSON.stringify({{
deferred, unavailable, writes, changes,
later:saved.later.map(item => [item.notification_id,item.deferred_until]),
unchanged:beforeFailure === JSON.stringify(store.read()),
}}));
"""
result = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
assert json.loads(result.stdout) == {
"deferred": "deferred",
"unavailable": "unavailable",
"writes": 2,
"changes": [
["defer", "update:stackchain/api:17:91", "2026-08-09T09:00:00.000Z"],
["defer", "update:stackchain/web:8:92", "2026-08-09T09:00:00.000Z"],
],
"later": [
[91, "2026-08-09T09:00:00.000Z"],
[92, "2026-08-09T09:00:00.000Z"],
],
"unchanged": True,
}
def test_later_queue_prunes_missing_work_and_wakes_expired_items_without_reload():
script = f"""
const createLaterWork = require({json.dumps(str(LATER_WORK))});
@@ -3014,7 +3502,10 @@ controller.load().then(() => controller.loadMore()).then(() =>
)
output = json.loads(result.stdout)
assert output["calls"] == ["api/v1/available-issues?page=1", "api/v1/available-issues?page=2"]
assert output["calls"] == [
"api/v1/available-issues?page=1&facets=true",
"api/v1/available-issues?page=2&facets=true",
]
assert [item["id"] for item in output["items"]] == [1, 2]
assert output["pages"][-1] == {"page": 2, "total": 2, "has_more": False}
@@ -4076,6 +4567,58 @@ const reader = buildMyWork.createNotificationReader({{
assert output["results"][1] is False
def test_notification_undo_restores_exact_item_once_and_keeps_failure_retryable():
script = f"""
const createNotificationUndo = require({json.dumps(str(NOTIFICATION_UNDO))});
const states = [];
const statuses = [];
const calls = [];
let attempts = 0;
const item = {{kind:'update', key:'repo#7', notification_id:42, has_update:true}};
const undo = createNotificationUndo({{
restore: async id => {{ calls.push(id); attempts += 1; if (attempts === 1) throw new Error('offline'); }},
onItems: items => states.push(items.map(value => value.notification_id)),
onStatus: status => statuses.push(status),
}});
(async () => {{
undo.offer(item, [{{kind:'update', notification_id:43, has_update:true}}]);
const first = undo.run();
const duplicate = undo.run();
const firstResults = await Promise.all([first, duplicate]);
const retry = await undo.run();
process.stdout.write(JSON.stringify({{calls, states, statuses, firstResults, retry}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output["calls"] == [42, 42]
assert output["states"] == [[42, 43]]
assert output["firstResults"] == [False, False]
assert output["retry"] is True
assert output["statuses"] == [
"repo#7 marked read. Undo?",
"Restoring repo#7…",
"Could not restore repo#7. Retry Undo.",
"Restoring repo#7…",
"repo#7 is unread again.",
]
@pytest.mark.anyio
async def test_mobile_update_undo_is_accessible_and_safe_area_aware():
html = await dashboard()
assert 'id="notification-undo"' in html
assert 'id="undo-notification" type="button"' in html
assert "requestNotificationUnread" in html
assert "createDashboardNotificationUndo({" in html
assert ".notification-undo { position:fixed;" in html
assert "env(safe-area-inset-bottom)" in html
assert ".notification-undo button { min-height:44px;" in html
@pytest.mark.anyio
async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next():
html = await dashboard()
@@ -4354,7 +4897,9 @@ reader.open(item, [item]).then(() => {{
)
output = json.loads(result.stdout)
assert output["result"] == {"items": [], "next": None}
assert output["result"] == {"items": [], "item": {
"kind": "update", "notification_id": 42, "has_update": True,
}, "next": None}
assert output["events"] == [
["status", "Marking update read…"],
["items", []],
@@ -5412,6 +5957,39 @@ async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
assert "body: JSON.stringify({ ids })" in html
@pytest.mark.anyio
async def test_selected_updates_can_be_deferred_together_without_marking_them_read():
html = await dashboard()
assert 'id="defer-selected-today"' in html
assert 'id="defer-selected-tomorrow"' in html
assert 'laterWork.deferMany(selectedUpdates, until)' in html
assert "notificationSelection.cancel()" in html
assert "Selection unchanged." in html
assert "work stays unread and unchanged in Gitea" in html
assert '.my-work-bulk-actions { display:grid;' in html
assert '.my-work-bulk-actions button { min-height:44px; width:100%; }' in html
@pytest.mark.anyio
async def test_mobile_work_queues_batch_plan_cross_kind_selection():
html = await dashboard()
service_worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert '<script src="static/work-selection.js"></script>' in html
assert 'id="select-work"' in html
assert 'id="batch-add-today"' in html
assert 'createWorkSelection({ limit: 50' in html
assert 'todayWork.addMany(selectedItems)' in html
assert 'laterWork.deferMany(selectedItems, until)' in html
assert 'data-select-work-id=' in html
assert 'Select work ' in html
assert "workSelectionState.count + ' selected'" in html
assert '.work-selector { min-height:44px;' in html
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
assert "BASE + 'static/work-selection.js'" in service_worker
@pytest.mark.anyio
async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile():
html = await dashboard()
@@ -5440,7 +6018,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert 'data-work-count="later"' in html
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+43
View File
@@ -41,6 +41,29 @@ async def test_mark_notification_read_calls_supported_gitea_thread_endpoint(monk
]
@pytest.mark.anyio
async def test_mark_notification_unread_calls_supported_gitea_thread_endpoint(monkeypatch):
calls = []
class Response:
def raise_for_status(self):
return None
class Client:
async def patch(self, path, headers):
calls.append((path, headers))
return Response()
monkeypatch.setattr(gitea_proxy, "_get_client", lambda: Client())
monkeypatch.setattr(gitea_proxy, "_auth", lambda: {"Authorization": "token test"})
await gitea_proxy.mark_notification_unread(42)
assert calls == [
("/api/v1/notifications/threads/42?to-status=unread", {"Authorization": "token test"})
]
@pytest.mark.anyio
async def test_acknowledge_notification_resolves_latest_comment_and_adds_one_reaction(monkeypatch):
calls = []
@@ -172,6 +195,26 @@ async def test_mark_notification_read_api_is_bounded_and_never_cacheable(monkeyp
assert marked == [42]
@pytest.mark.anyio
async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monkeypatch):
restored = []
async def mark_unread(thread_id):
restored.append(thread_id)
monkeypatch.setattr(main.gitea_proxy, "mark_notification_unread", mark_unread, raising=False)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch("/api/v1/notifications/42/unread")
invalid = await client.patch("/api/v1/notifications/0/unread")
assert response.status_code == 200
assert response.json() == {"id": 42, "status": "unread"}
assert response.headers["cache-control"] == "no-store"
assert invalid.status_code == 422
assert restored == [42]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
+41
View File
@@ -104,3 +104,44 @@ def test_existing_challenge_registry_is_migrated_without_losing_live_challenges(
).fetchall()
assert "source_hash" in columns
assert sorted(rows) == sorted([("existing",), (store._digest(b"new"),)])
def test_passkey_counter_advancement_is_atomic_across_store_instances(tmp_path):
database = tmp_path / "passkeys.sqlite3"
first = PasskeyStore(database, clock=lambda: 1_000.0)
second = PasskeyStore(database, clock=lambda: 1_000.0)
first.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
assert first.advance_counter(b"phone-credential", expected=4, new=5) is True
assert second.advance_counter(b"phone-credential", expected=4, new=5) is False
assert first.get(b"phone-credential").sign_count == 5
def test_passkey_counter_rejects_non_advancing_values_but_supports_counterless_devices(
tmp_path,
):
store = PasskeyStore(tmp_path / "passkeys.sqlite3", clock=lambda: 1_000.0)
store.register(
credential_id=b"phone-credential",
public_key=b"phone-public-key",
sign_count=4,
device_label="Phone",
management_id="phone-management-id",
)
store.register(
credential_id=b"counterless-credential",
public_key=b"counterless-public-key",
sign_count=0,
device_label="Security key",
management_id="counterless-management-id",
)
assert store.advance_counter(b"phone-credential", expected=4, new=4) is False
assert store.advance_counter(b"phone-credential", expected=4, new=3) is False
assert store.advance_counter(b"counterless-credential", expected=0, new=0) is True
+2 -1
View File
@@ -410,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-v94" in source
assert "stackchain-dashboard-shell-v99" 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
+106
View File
@@ -0,0 +1,106 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
PROTECT_TODAY = ROOT / "frontend" / "protect-today.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_protect_today_prioritizes_and_deduplicates_urgent_deadlines_before_existing_plan():
script = f"""
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
const issue = (number, group, due) => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number,
is_assigned:true, state:'open', agenda_group:group, due_date:due,
}});
const urgentLater = issue(3, 'Today', '2026-08-13');
const urgentFirst = issue(2, 'Overdue', '2026-08-12');
const duplicate = issue(2, 'Overdue', '2026-08-12');
const existingUrgent = issue(1, 'Today', '2026-08-13');
const existingNormal = issue(8, null, null);
const result = protectToday.propose({{
agenda:[urgentLater, duplicate, urgentFirst, issue(4, 'Tomorrow', '2026-08-14')],
today:[existingNormal, existingUrgent],
identity:item => item.repository + '#' + item.number,
limit:5,
}});
process.stdout.write(JSON.stringify({{
selected:result.selected.map(item => item.number),
protected:result.protected.map(item => item.number),
displaced:result.displaced.map(item => item.number),
summary:result.summary,
}}));
"""
assert run_node(script) == {
"selected": [2, 1, 3, 8],
"protected": [2, 1, 3],
"displaced": [],
"summary": "3 urgent items protected · 0 displaced",
}
def test_protect_today_reports_existing_work_displaced_by_five_item_limit():
script = f"""
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
const issue = (number, group = null) => ({{
kind:'issue', repository:'stackchain/dashboard', number, title:'Issue ' + number,
is_assigned:true, state:'open', agenda_group:group,
due_date:group === 'Overdue' ? '2026-08-12' : group === 'Today' ? '2026-08-13' : null,
}});
const result = protectToday.propose({{
agenda:[issue(10, 'Overdue'), issue(11, 'Today'), issue(12, 'Today')],
today:[issue(1), issue(2), issue(3), issue(4), issue(5)],
identity:item => item.repository + '#' + item.number,
limit:5,
}});
process.stdout.write(JSON.stringify({{
selected:result.selected.map(item => item.number),
displaced:result.displaced.map(item => item.number),
summary:result.summary,
}}));
"""
assert run_node(script) == {
"selected": [10, 11, 12, 1, 2],
"displaced": [3, 4, 5],
"summary": "3 urgent items protected · 3 displaced for review",
}
def test_protect_today_excludes_closed_unassigned_and_nonurgent_items():
script = f"""
const protectToday = require({json.dumps(str(PROTECT_TODAY))});
const items = [
{{kind:'issue',repository:'r',number:1,is_assigned:true,state:'closed',agenda_group:'Overdue',due_date:'2026-08-01'}},
{{kind:'issue',repository:'r',number:2,is_assigned:false,state:'open',agenda_group:'Today',due_date:'2026-08-13'}},
{{kind:'issue',repository:'r',number:3,is_assigned:true,state:'open',agenda_group:'Tomorrow',due_date:'2026-08-14'}},
{{kind:'pull',repository:'r',number:4,is_assigned:true,state:'open',agenda_group:'Overdue',due_date:'2026-08-01'}},
];
const result = protectToday.propose({{agenda:items,today:[],identity:item => item.repository + '#' + item.number}});
process.stdout.write(JSON.stringify(result));
"""
assert run_node(script) == {
"selected": [], "protected": [], "displaced": [],
"summary": "No overdue or due-today work needs protection.",
}
def test_dashboard_renders_mobile_protect_today_entrypoint_and_preview_context():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
css = (ROOT / "frontend" / "dashboard.css").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="protect-today"' in html
assert 'id="protect-today-status"' in html
assert "protectToday.propose" in dashboard
assert "pendingProtectToday" in dashboard
assert "Protect Today" in dashboard
assert ".protect-today" in css
assert "min-height:44px" in css.replace(" ", "")
assert '"static/protect-today.js"' in bundle
+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)
+76 -1
View File
@@ -14,6 +14,12 @@ const control = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.change = callback,
};
const deadlineControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.deadlineChange = callback,
};
const deadlineHour = {value:'9', disabled:false, addEventListener:(_name, callback) => state.deadlineHourChange = callback};
const deadlineStatus = {set textContent(value) { state.deadlineText = value; }, get textContent() { return state.deadlineText; }};
const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }};
const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }};
const registration = {pushManager:{
@@ -21,7 +27,7 @@ const registration = {pushManager:{
subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; },
}};
const feature = createPushNotifications({
control, status,
control, status, deadlineControl, deadlineStatus, deadlineHour,
notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }},
serviceWorker: {ready:Promise.resolve(registration)},
fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; },
@@ -57,6 +63,72 @@ process.stdout.write(JSON.stringify(state));
assert result["text"] == "New update notifications enabled for this device."
def test_deadline_opt_in_reuses_subscription_and_sends_local_timezone_without_second_prompt():
result = run_scenario("""
state.current = existing;
state.server = {available:true,subscribed:true,deadline_enabled:false,public_key:'AQID'};
await feature.init();
deadlineControl.checked = true;
await state.deadlineChange();
process.stdout.write(JSON.stringify(state));
""")
assert result["prompts"] == 0
assert result["subscriptions"] == []
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/deadlines", "PUT"]
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["reminder_hour"] == 9
assert isinstance(body["timezone"], str) and body["timezone"]
assert result["deadlineText"] == "Deadline reminders enabled for 09:00 local time."
def test_deadline_setup_subscribes_once_and_persists_selected_local_hour():
result = run_scenario("""
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
await feature.init();
deadlineHour.value = '8';
await feature.enableDeadline();
process.stdout.write(JSON.stringify(state));
""")
assert result["prompts"] == 1
assert len(result["subscriptions"]) == 1
assert [request[0] for request in result["requests"][-2:]] == [
"api/v1/push-subscription", "api/v1/push-subscription/deadlines",
]
body = json.loads(result["requests"][-1][2])
assert body["enabled"] is True
assert body["reminder_hour"] == 8
assert result["deadlineText"] == "Deadline reminders enabled for 08:00 local time."
def test_deadline_setup_restores_confirmed_hour_and_denial_stays_incomplete():
restored = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({hour:deadlineHour.value, checked:deadlineControl.checked, readiness:feature.deadlineReadiness()}));
""")
assert restored == {
"hour": "17", "checked": True,
"readiness": {"state": "complete", "detail": "Deadline reminders enabled for 17:00 local time."},
}
denied = run_scenario("""
state.server = {available:true,subscribed:false,deadline_enabled:false,reminder_hour:9,public_key:'AQID'};
state.permission = 'denied';
await feature.init();
deadlineHour.value = '8';
await feature.enableDeadline();
process.stdout.write(JSON.stringify({checked:deadlineControl.checked, readiness:feature.deadlineReadiness(), requests:state.requests}));
""")
assert denied["checked"] is False
assert denied["readiness"]["state"] == "incomplete"
assert "blocked" in denied["readiness"]["detail"].lower()
assert [request[0] for request in denied["requests"]] == ["api/v1/push-subscription"]
def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
root = MODULE.parents[1]
html = (root / "frontend" / "index.html").read_text()
@@ -68,6 +140,9 @@ def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller():
assert 'id="push-updates"' in html
assert 'id="push-update-status"' in html
assert 'id="push-deadlines"' in html
assert 'id="push-deadline-hour"' in html
assert 'id="push-deadline-status"' in html
assert '<script src="static/push-notifications.js"></script>' in html
assert "createPushNotifications({" in dashboard
assert "BASE + 'static/push-notifications.js'" in worker
+329 -7
View File
@@ -2,6 +2,7 @@ import json
import asyncio
import os
import sqlite3
import time
from types import SimpleNamespace
import httpx
@@ -10,6 +11,15 @@ import pytest
from src import dashboard_auth, gitea_proxy, main
from src.push_notifications import PushConfiguration, dispatch_unread_updates
from src.push_subscription_store import PushSubscriptionStore
from src.push_endpoint_policy import UnsafePushEndpoint
@pytest.fixture(autouse=True)
def resolve_test_push_endpoints_publicly(monkeypatch):
async def accept(endpoint):
return endpoint
monkeypatch.setattr(main, "validate_public_push_endpoint", accept)
def test_dispatch_lease_is_exclusive_recoverable_and_owner_fenced(tmp_path):
@@ -194,6 +204,24 @@ async def test_managed_session_active_applies_configured_idle_deadline(monkeypat
assert calls == [("active-device", 321), ("idle-device", 321)]
@pytest.mark.anyio
async def test_managed_session_statuses_apply_configured_idle_deadline_in_one_call(monkeypatch):
calls = []
class Store:
def managed_statuses(self, management_ids, *, idle_timeout_seconds):
calls.append((list(management_ids), idle_timeout_seconds))
return {management_id: "active" for management_id in set(management_ids)}
monkeypatch.setenv("STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS", "321")
monkeypatch.setattr(dashboard_auth, "_session_store", lambda: Store())
assert await dashboard_auth.managed_session_statuses(
["device-b", "device-a", "device-b"]
) == {"device-a": "active", "device-b": "active"}
assert calls == [(["device-b", "device-a", "device-b"], 321)]
@pytest.mark.anyio
async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch):
captured = {}
@@ -215,11 +243,141 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
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["session_statuses"] is dashboard_auth.managed_session_statuses
assert captured["max_concurrency"] == 3
assert captured["max_individual_notifications"] == 4
@pytest.mark.anyio
async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(monkeypatch):
calls = []
sleeps = 0
first_tick = asyncio.Event()
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
if sleeps <= 2:
if sleeps == 2:
first_tick.set()
await first_tick.wait()
else:
raise asyncio.CancelledError
async def fail_unread(*_args, **_kwargs):
calls.append("unread")
raise RuntimeError("unread unavailable")
async def dispatch_deadlines(*_args, **_kwargs):
calls.append("deadline")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert sorted(calls) == ["deadline", "unread"]
@pytest.mark.anyio
async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(monkeypatch):
unread_started = asyncio.Event()
release_unread = asyncio.Event()
deadline_started = asyncio.Event()
async def no_wait(_seconds):
return None
async def blocked_unread(*_args, **_kwargs):
unread_started.set()
await release_unread.wait()
async def dispatch_deadlines(*_args, **_kwargs):
deadline_started.set()
await release_unread.wait()
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
poll = asyncio.create_task(main._push_poll_loop())
await asyncio.wait_for(unread_started.wait(), timeout=0.5)
try:
await asyncio.wait_for(deadline_started.wait(), timeout=0.5)
finally:
poll.cancel()
with pytest.raises(asyncio.CancelledError):
await poll
@pytest.mark.anyio
async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
intervals = []
async def capture_channel(_dispatch, *, interval):
intervals.append(interval)
monkeypatch.setenv("STACKCHAIN_PUSH_POLL_SECONDS", "30")
monkeypatch.setenv("STACKCHAIN_DEADLINE_POLL_SECONDS", "600")
monkeypatch.setattr(main, "_push_channel_loop", capture_channel)
await main._push_poll_loop()
assert sorted(intervals) == [30.0, 600.0]
@pytest.mark.anyio
async def test_push_channel_loop_skips_missed_ticks_without_overlapping(monkeypatch):
starts = []
now = 0.0
class Loop:
def time(self):
return now
async def record_dispatch():
nonlocal now
starts.append("dispatch")
now = 16.0 if len(starts) == 1 else now
if len(starts) == 2:
raise asyncio.CancelledError
sleeps = []
async def capture_sleep(seconds):
nonlocal now
sleeps.append(seconds)
now += seconds
monkeypatch.setattr(main.asyncio, "get_running_loop", lambda: Loop())
monkeypatch.setattr(main.asyncio, "sleep", capture_sleep)
with pytest.raises(asyncio.CancelledError):
await main._push_channel_loop(record_dispatch, interval=5.0)
assert starts == ["dispatch", "dispatch"]
assert sleeps == [5.0, 4.0]
def test_dispatch_leases_are_isolated_by_channel(tmp_path):
path = tmp_path / "push.sqlite3"
first = PushSubscriptionStore(path)
second = PushSubscriptionStore(path)
assert first.acquire_dispatch_lease(
"unread-owner", channel="unread", now=100, lease_seconds=30
) is True
assert second.acquire_dispatch_lease(
"deadline-owner", channel="deadline", now=100, lease_seconds=30
) is True
assert second.acquire_dispatch_lease(
"other-unread-owner", channel="unread", now=100, lease_seconds=30
) is False
def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
original = {
@@ -508,6 +666,32 @@ async def test_dispatch_removes_an_expired_push_endpoint(tmp_path):
assert store.is_subscribed("session-a") is False
@pytest.mark.anyio
async def test_dispatch_removes_an_endpoint_that_rebinds_private_without_contacting_it(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("unsafe-device", {
"endpoint": "https://push.example/rebound",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
contacted = []
async def unread():
return {"items": [{"id": 8}]}
async def reject_rebound(_endpoint):
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
async def send(subscription, _payload):
contacted.append(subscription["endpoint"])
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(
store, config, unread, send, endpoint_validator=reject_rebound
) == 0
assert contacted == []
assert store.is_subscribed("unsafe-device") is False
@pytest.mark.anyio
async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -532,6 +716,75 @@ async def test_transient_endpoint_failure_does_not_block_healthy_devices(tmp_pat
assert store.claim_unseen({8})[0].session_id == "session-a"
@pytest.mark.anyio
async def test_unexpected_device_failure_waits_for_siblings_and_retries_only_that_device(tmp_path):
path = tmp_path / "push.sqlite3"
store = PushSubscriptionStore(path)
competitor = PushSubscriptionStore(path)
for session_id in ("failing-device", "healthy-device"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
healthy_started = asyncio.Event()
release_healthy = asyncio.Event()
sent = []
async def unread():
return {"items": [{"id": 8, "updated_at": "r1"}], "complete": True}
async def validate(endpoint):
if endpoint.endswith("failing-device"):
raise TimeoutError("endpoint validation unavailable")
return endpoint
async def send(subscription, _payload):
sent.append(subscription["endpoint"])
healthy_started.set()
await release_healthy.wait()
config = PushConfiguration("public", "private", "mailto:ops@example.com")
dispatch = asyncio.create_task(dispatch_unread_updates(
store,
config,
unread,
send,
endpoint_validator=validate,
))
await asyncio.wait_for(healthy_started.wait(), timeout=0.2)
await asyncio.sleep(0)
assert dispatch.done() is False
assert competitor.acquire_dispatch_lease(
"competing-worker", now=time.time(), lease_seconds=60
) is False
release_healthy.set()
assert await dispatch == 1
remaining = store.claim_unseen({8: "r1"})
assert [(item.session_id, item.thread_ids) for item in remaining] == [
("failing-device", (8,))
]
retried = []
async def validate_recovered(endpoint):
return endpoint
async def send_retry(subscription, _payload):
retried.append(subscription["endpoint"])
assert await dispatch_unread_updates(
store,
config,
unread,
send_retry,
endpoint_validator=validate_recovered,
) == 1
assert retried == ["https://push.example/failing-device"]
assert sent == ["https://push.example/healthy-device"]
@pytest.mark.anyio
async def test_transient_failure_stops_that_device_until_the_next_poll(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -646,19 +899,26 @@ async def test_dispatch_removes_inactive_sessions_without_blocking_active_device
async def unread():
return {"items": [{"id": 21}]}
async def session_active(management_id):
return management_id == "active-device"
authorization_calls = []
async def session_statuses(management_ids):
authorization_calls.append(list(management_ids))
return {
management_id: "active" if management_id == "active-device" else "revoked"
for management_id in management_ids
}
async def send(subscription, _payload):
sent.append(subscription["endpoint"])
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(
store, config, unread, send, session_active=session_active
store, config, unread, send, session_statuses=session_statuses
) == 1
assert store.is_subscribed("expired-device") is False
assert store.is_subscribed("active-device") is True
assert sent == ["https://push.example/active-device"]
assert authorization_calls == [["active-device", "expired-device"]]
@pytest.mark.anyio
@@ -673,7 +933,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
async def unread():
return {"items": [{"id": 22}]}
async def registry_unavailable(_management_id):
async def registry_unavailable(_management_ids):
raise RuntimeError("session registry unavailable")
async def send(subscription, _payload):
@@ -681,7 +941,7 @@ async def test_dispatch_fails_closed_and_retains_subscriptions_when_session_regi
config = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch_unread_updates(
store, config, unread, send, session_active=registry_unavailable
store, config, unread, send, session_statuses=registry_unavailable
) == 0
assert store.is_subscribed("active-device") is True
assert sent == []
@@ -714,7 +974,12 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
)
assert await main.push_status(request) == {
"available": True, "subscribed": False, "public_key": "public-vapid"
"available": True,
"subscribed": False,
"public_key": "public-vapid",
"deadline_enabled": False,
"timezone": "UTC",
"reminder_hour": 9,
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is True
@@ -722,6 +987,63 @@ 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_authenticated_device_can_enable_deadline_reminders(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/device-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.DeadlineReminderPayload(
enabled=True, timezone="America/New_York", reminder_hour=9
)
assert await main.update_deadline_reminders(payload, request) == {
"deadline_enabled": True,
"timezone": "America/New_York",
"reminder_hour": 9,
}
assert store.deadline_preferences("session-a")["enabled"] is True
@pytest.mark.anyio
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
monkeypatch.setattr(main, "_push_subscription_store", store)
monkeypatch.setattr(
main, "_push_configuration",
lambda: PushConfiguration("public", "private", "mailto:ops@example.com"),
)
async def management_id(_session):
return "unsafe-device"
async def reject_private(_endpoint):
raise UnsafePushEndpoint("Endpoint must resolve to a public Web Push service")
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
monkeypatch.setattr(main, "validate_public_push_endpoint", reject_private)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload = main.PushSubscriptionPayload(
endpoint="https://push.example/device-a",
keys={"p256dh": "public-key", "auth": "auth-secret"},
)
with pytest.raises(main.HTTPException) as rejected:
await main.subscribe_push(payload, request)
assert rejected.value.status_code == 422
assert rejected.value.detail == "Endpoint must resolve to a public Web Push service"
assert store.is_subscribed("unsafe-device") is False
@pytest.mark.anyio
async def test_subscription_baselines_every_revision_from_the_complete_snapshot(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+39
View File
@@ -47,3 +47,42 @@ const boundary={{
"hidden": False,
"focused": True,
}
def test_counter_anomaly_activity_gives_passkey_remediation_guidance():
harness = f"""
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
const element=()=>({{
hidden:true, disabled:false, textContent:'', children:[], className:'',
addEventListener(){{}}, focus(){{}}, replaceChildren(){{this.children=[];}},
append(...values){{this.children.push(...values);}},
}});
const ids={{
'active-devices':element(), 'active-devices-sheet':element(),
'active-devices-list':element(), 'active-devices-status':element(),
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
'enroll-passkey':element(), 'security-activity-list':element(),
'security-activity-status':element(), 'load-more-security-activity':element(),
'close-active-devices':element(),
}};
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
const boundary={{
listActiveDevices:async()=>[], listPasskeys:async()=>[],
listSecurityEvents:async()=>({{events:[{{
kind:'passkey_counter_anomaly', device_label:'Phone', method:'passkey',
target:'sign_in:dashboard', status:'completed', created_at:1,
}}],authentication_alerts:[],next_cursor:null}}),
}};
(async()=>{{
await attachSecurityCenter({{root,boundary}}).open();
const row=ids['security-activity-list'].children[0];
console.log(JSON.stringify(row.children.map(child=>child.textContent)));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
completed = subprocess.run(
["node", "-e", harness], check=True, capture_output=True, text=True
)
title, detail = json.loads(completed.stdout)
assert title == "Passkey counter anomaly"
assert "Remove and re-enroll this passkey" in detail
+67 -10
View File
@@ -145,7 +145,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/my-work.js'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/dashboard.css'" in source
@@ -154,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v94" in source
assert "stackchain-dashboard-shell-v99" 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-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v94" in source
assert "stackchain-dashboard-shell-v99" 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-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -193,21 +193,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v94" in source
assert "stackchain-dashboard-shell-v99" 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-v94" in source
assert "stackchain-dashboard-shell-v99" 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-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/update-ownership.js'" in source
@@ -459,6 +459,55 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
assert "must-not-render" not in json.dumps(result["notifications"])
def test_deadline_digest_push_offers_protect_today_and_agenda_without_rendering_private_copy():
result = run_worker_scenario(
"""
await dispatchPush({
title:'must-not-render', body:'private details must-not-render',
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
protect_route:'#/my-work/agenda/protect-today', deadline_count:3,
});
await dispatchNotificationClick('#/my-work/agenda/protect-today', 'protect-today', null, 'stackchain-deadline-digest-2026-08-13');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [{
"title": "3 deadlines need attention",
"options": {
"body": "Open Agenda to review or replan them.",
"tag": "stackchain-deadline-digest-2026-08-13",
"actions": [
{"action": "protect-today", "title": "Protect Today"},
{"action": "open-agenda", "title": "Open Agenda"},
],
"data": {
"route": "#/my-work/agenda",
"protectRoute": "#/my-work/agenda/protect-today",
},
},
}]
assert result["opened"] == [
"https://forge.example/dashboard/#/my-work/agenda/protect-today"
]
assert "must-not-render" not in json.dumps(result["notifications"])
def test_deadline_digest_open_agenda_action_preserves_browsing_flow():
result = run_worker_scenario(
"""
await dispatchPush({
tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda',
protect_route:'#/my-work/agenda/protect-today', deadline_count:1,
});
await dispatchNotificationClick('#/my-work/agenda', 'open-agenda', null, 'stackchain-deadline-digest-2026-08-13');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["opened"] == ["https://forge.example/dashboard/#/my-work/agenda"]
def test_push_mark_read_action_confirms_authenticated_mutation_without_opening_app():
result = run_worker_scenario(
"""
@@ -654,7 +703,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-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -695,7 +744,11 @@ 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/agenda-replan.js",
"/dashboard/static/protect-today.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",
@@ -704,16 +757,19 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/comment-next.js",
"/dashboard/static/update-reply-read-next.js",
"/dashboard/static/plan-today.js",
"/dashboard/static/plan-today-readiness.js",
"/dashboard/static/plan-today-preview.js",
"/dashboard/static/today-sync.js",
"/dashboard/static/today-rollover.js",
"/dashboard/static/update-ownership.js",
"/dashboard/static/update-follow-up.js",
"/dashboard/static/later-work.js",
"/dashboard/static/later-sync.js",
"/dashboard/static/later-and-start.js",
"/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",
@@ -729,6 +785,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/context-poller.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
+39
View File
@@ -102,6 +102,45 @@ def test_managed_session_status_enforces_absolute_and_idle_expiry(tmp_path):
assert store.managed_status("missing-device", idle_timeout_seconds=900) == "revoked"
def test_managed_session_statuses_classify_a_batch_with_one_read(tmp_path, monkeypatch):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
store.activate("active-session", 3_000, management_id="active-device")
store.activate("idle-session", 3_000, management_id="idle-device")
store.activate("expired-session", 1_500, management_id="expired-device")
now[0] = 2_000.0
store.touch("active-session", 3_000, idle_timeout_seconds=1_500)
statements = []
connect = sqlite3.connect
def traced_connect(*args, **kwargs):
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(session_store.sqlite3, "connect", traced_connect)
assert store.managed_statuses(
["active-device", "idle-device", "expired-device", "missing-device", "active-device"],
idle_timeout_seconds=900,
) == {
"active-device": "active",
"idle-device": "idle",
"expired-device": "revoked",
"missing-device": "revoked",
}
assert [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")] == [
next(statement for statement in statements if "FROM active_sessions" in statement)
]
def test_managed_session_statuses_skips_database_for_an_empty_batch(tmp_path, monkeypatch):
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0)
monkeypatch.setattr(store, "_connect", lambda *args, **kwargs: pytest.fail("database opened"))
assert store.managed_statuses([], idle_timeout_seconds=900) == {}
def test_touch_extends_only_the_matching_live_session(tmp_path):
now = [1_000.0]
store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0])
+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-v94';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v99';" in service_worker
assert "BASE + 'static/today-readiness.js'" in service_worker
+20 -10
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import subprocess
import time
from pathlib import Path
import httpx
@@ -275,6 +274,8 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
active = 0
peak = 0
attempted = []
first_wave_started = asyncio.Event()
release_first_wave = asyncio.Event()
async def user():
return {"id": 1, "login": "Timmy"}
@@ -284,8 +285,10 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
active += 1
peak = max(peak, active)
attempted.append(identity)
if len(attempted) == 3:
first_wave_started.set()
try:
await asyncio.sleep(0.1)
await release_first_wave.wait()
finally:
active -= 1
@@ -305,17 +308,24 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
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},
request = asyncio.create_task(
client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
)
elapsed = time.perf_counter() - started
await asyncio.wait_for(first_wave_started.wait(), timeout=1)
await asyncio.sleep(0)
assert active == 3
assert peak == 3
assert len(attempted) == 3
assert not request.done()
release_first_wave.set()
response = await request
assert response.status_code == 200
assert elapsed < 0.35
assert 1 < peak <= 3
assert peak == 3
assert set(attempted) == set(identities)
assert response.json()["time_logs"] == [
{"identity": identity, "status": "logged"} for identity in identities
+14
View File
@@ -67,6 +67,20 @@ def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
}
def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, timeout=0.05)
expected = store.apply("timmy", "seed", "add", "issue:r:1:")
writer = sqlite3.connect(path)
writer.execute("BEGIN IMMEDIATE")
try:
assert TodayStore(path, timeout=0.05).get("timmy") == expected
finally:
writer.rollback()
writer.close()
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, limit=3)
+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-v94" in source
assert "stackchain-dashboard-shell-v99" in source
assert "BASE + 'static/today-sync.js'" in source
+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