Compare commits

...
Author SHA1 Message Date
timmy 82982e5741 Merge pull request 'Prefetch the next mobile update during triage' (#758) from timmy/757-prefetch-next-mobile-update 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 17:33:37 +00:00
timmy db08c7e75f feat: prefetch the next mobile update (Closes #757)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 17:31:20 +00:00
timmy 1a3df5d7a7 Merge pull request 'Make the mobile Work action launch the highest-priority queue' (#756) from timmy/755-priority-mobile-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-13 17:02:12 +00:00
timmy fdba0554df feat: prioritize the mobile Work action (Closes #755)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:59:36 +00:00
timmy d2a85cd5ea Merge pull request 'Bound mobile Updates discovery with one atomic snapshot' (#754) from timmy/753-atomic-updates-snapshot into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 16:27:32 +00:00
timmy 1f034a6f64 feat: make Updates discovery atomic (Closes #753)
CI / lint (pull_request) Successful in 1m43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 16:25:03 +00:00
timmy de6d6d0e61 Merge pull request 'Mute future updates during mobile triage' (#752) from timmy/751-mute-future-updates 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 15:57:37 +00:00
timmy 8dc3880167 feat: mute future updates during triage (Closes #751)
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 15:55:02 +00:00
timmy b93e63ab70 Merge pull request 'Complete unread discovery before mobile Updates triage' (#750) from timmy/749-complete-updates-discovery 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 15:34:15 +00:00
timmy 8c82644515 feat: complete Updates discovery before triage (Closes #749)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 15:28:17 +00:00
timmy 6c6cd3c71b Merge pull request 'Resume interrupted mobile Updates triage' (#748) from timmy/747-resumable-updates-triage into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m40s
2026-08-13 15:00:45 +00:00
timmy 4f6cebae7c feat: resume mobile Updates triage (Closes #747)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 14:58:19 +00:00
timmy 341a3283ea Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 7s
CI / lint (push) Successful in 1m35s
Merge pull request 'Make live data status truthful and actionable' (#746) from timmy/745-live-data-status into main
2026-08-13 14:28:33 +00:00
timmy 9c276fa251 feat: make live data status actionable (Closes #745)
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 14:26:09 +00:00
timmy 940f7c05d2 Merge pull request 'Make the mobile Queues summary truthful' (#744) from timmy/743-truthful-mobile-queue-summary into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m36s
2026-08-13 13:55:28 +00:00
timmy d34c082da2 fix: make mobile queue summary truthful (Closes #743)
CI / lint (pull_request) Successful in 1m37s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:53:04 +00:00
timmy 1c086be09b Merge pull request 'Preserve shared screenshots through operator sign-in' (#742) from timmy/741-preserve-shared-screenshot-signin into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m31s
2026-08-13 13:27:39 +00:00
timmy 4cfcb92eec fix: preserve shared screenshots through sign-in (Closes #741)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 13:25:26 +00:00
timmy b484bbef03 Merge pull request 'Share screenshots directly into mobile capture' (#740)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m29s
Closes #739
2026-08-13 12:58:07 +00:00
timmy 623817eb7d feat: share screenshots into mobile capture (Closes #739)
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 12:55:47 +00:00
timmy 11347e5fe0 Merge pull request 'Launch installed mobile work from task-specific app shortcuts' (#738) from timmy/737-pwa-task-shortcuts into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m35s
2026-08-13 12:39:20 +00:00
timmy bb1712279c feat: launch mobile work from PWA shortcuts (Closes #737)
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-13 12:26:27 +00:00
timmy e7d5196987 Merge pull request 'Wait for complete Agenda discovery before starting a session' (#736)
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
CI / lint (push) Successful in 1m26s
Closes #735
2026-08-13 11:58:30 +00:00
timmy 64ff5127d8 fix: wait for complete Agenda discovery (Closes #735)
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 11:56:23 +00:00
timmy 4ac5a0f499 Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734) from timmy/733-resumable-mobile-agenda into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m32s
Merge pull request 'Make mobile Agenda sessions directly launchable and resumable' (#734)
2026-08-13 11:29:15 +00:00
timmy 715df380e0 feat: resume mobile Agenda sessions (Closes #733)
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-13 11:27:13 +00:00
timmy 1fc70f6890 Merge pull request 'Run the mobile Agenda as a sequential work session' (#732)
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m24s
Closes #731
2026-08-13 10:57:28 +00:00
timmy 523968f5a3 feat: run Agenda as a work session (Closes #731)
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 10:54:58 +00:00
timmy af27bb06e0 Merge pull request 'Bound assigned-issue reminder snapshots' (#730) from timmy/729-bounded-assigned-snapshot into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m29s
2026-08-13 10:26:37 +00:00
timmy 4410af09a6 Bound assigned-issue reminder snapshots
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-13 10:24:23 +00:00
timmy 880f60831a Merge pull request 'Configure the deadline reminder horizon per device' (#728) from timmy/727-configurable-deadline-horizon into main
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 5s
CI / lint (push) Successful in 1m26s
2026-08-13 09:57:25 +00:00
timmy e860d16d29 feat: configure deadline reminder horizon (Closes #727)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 09:55:09 +00:00
timmy abf8e23685 Merge pull request 'Bulk-authorize push devices before notification fan-out' (#726) from timmy/725-bulk-push-authorization into main
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
CI / lint (push) Successful in 1m27s
2026-08-13 09:31:45 +00:00
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
72 changed files with 6172 additions and 287 deletions

No files matched your search

+13 -2
View File
@@ -81,7 +81,11 @@ an open, unassigned issue update also offers **Take ownership & start**, which c
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
the session, and opens the issue. The adjacent **Take ownership** action remains available for
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
**Mark read & next** remains the explicit acknowledgement path. During an online Updates pass,
Stackchain preloads at most the next surviving conversation from the fixed snapshot while the
current one is being read. Advancing consumes that account-bound result without another detail
request; failures fall back to the normal foreground retry path, and offline triage never speculates.
Delivery or local-admission
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
@@ -210,6 +214,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.
@@ -373,7 +381,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;
+33
View File
@@ -0,0 +1,33 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createAgendaSessionLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createAgendaSessionLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectAgenda();
pending = Promise.resolve()
.then(() => options.discover())
.then(complete => {
if (!options.isAgendaSelected()) return 'cancelled';
if (complete === false || options.hasMore()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No deadlines are ready in Agenda.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isAgendaSelected()) {
options.announce('Agenda check paused. Retry to check older assigned deadlines.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+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;
+13
View File
@@ -37,6 +37,8 @@ function createContextPoller({
let retainedSnapshot = null;
let nextDelayMs = intervalMs;
let failureStreak = 0;
let nextRetryAt = null;
let lastSuccessAt = null;
function snapshotDelay(snapshot) {
const freshness = snapshot && snapshot.freshness;
@@ -69,8 +71,10 @@ function createContextPoller({
function schedule(delayMs = intervalMs) {
cancelTimer();
if (stopped || isHidden()) return;
nextRetryAt = Date.now() + delayMs;
timer = setTimer(() => {
timer = null;
nextRetryAt = null;
refresh();
}, delayMs);
}
@@ -125,6 +129,7 @@ function createContextPoller({
.then((snapshot) => {
if (activeRequest !== requestState) return retainedSnapshot;
failureStreak = 0;
lastSuccessAt = Date.now();
nextDelayMs = snapshotDelay(snapshot);
const changedSections = ['context', 'events', 'notifications'].filter(
(section) => Object.prototype.hasOwnProperty.call(snapshot, section)
@@ -165,6 +170,14 @@ function createContextPoller({
start: refresh,
refresh,
setVisible,
getState() {
return {
refreshing: Boolean(activeRequest),
nextRetryAt,
lastSuccessAt,
failureStreak,
};
},
stop() {
stopped = true;
cancelTimer();
+73 -2
View File
@@ -5,7 +5,17 @@ html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text);
header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex; gap:16px; align-items:center; justify-content:space-between; background: linear-gradient(180deg, rgba(11,21,38,.95), rgba(11,21,38,.55), transparent); backdrop-filter: blur(4px); border-bottom: 1px solid #1b2d45; }
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
.app-brand { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; }
.app-live-status { display:inline-flex; gap:6px; align-items:center; min-height:44px; padding:6px 10px; background:transparent; border-color:transparent; }
.live-data-status-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); }
.live-data-status-sheet[hidden] { display:none; }
.live-data-status-panel { box-sizing:border-box; width:min(560px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #31577f; border-radius:18px 18px 0 0; background:#0b1526; }
.live-data-status-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.live-data-status-header h2, .live-data-status-header p { margin-top:0; }
.live-data-status-header button, .live-data-status-actions button { min-height:44px; }
.live-data-status-feeds { display:grid; gap:8px; margin:14px 0; }
.live-data-status-feed { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.live-data-status-feed strong, .live-data-status-feed span { overflow-wrap:anywhere; }
.live-data-status-actions { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.app-menu { margin-left:auto; }
.app-menu > summary { display:none; }
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
@@ -38,6 +48,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, #push-deadline-days { 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; }
@@ -77,6 +90,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; }
@@ -235,12 +256,26 @@ 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; }
.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; }
@@ -348,6 +383,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-sheet-panel { width:min(560px,100%); height:100%; overflow:auto; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
.update-sheet-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.update-sheet-header button { min-height:44px; }
.update-triage-progress { margin:10px 0; padding:8px 10px; border:1px solid #2a496e; border-radius:10px; background:#10213a; }
.update-sheet-content { overflow-wrap:anywhere; white-space:pre-wrap; }
.update-reply { display:grid; gap:8px; margin-top:16px; }
.update-reply textarea { width:100%; min-height:112px; resize:vertical; }
@@ -365,6 +401,7 @@ textarea { resize: vertical; min-height: 120px; }
.update-ownership-actions button { min-width:0; width:100%; }
.update-sheet-actions button, .update-sheet-actions a { min-height:44px; display:flex; align-items:center; justify-content:center; }
.update-sheet-actions #acknowledge-update-next { min-height:44px; width:100%; }
.update-sheet-actions #mute-update-next { min-height:44px; width:100%; }
.update-sheet-actions a { border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
.update-retry { min-height:44px; width:100%; margin-top:10px; }
.issue-sheet { position:fixed; inset:0; z-index:56; display:none; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
@@ -451,9 +488,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; }
@@ -545,12 +611,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-count { min-width:18px; min-height:18px; max-width:100%; padding:1px 5px; border-radius:999px; background:#31577f; font-size:10px; line-height:16px; white-space:nowrap; }
.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) {
@@ -618,6 +686,7 @@ textarea { resize: vertical; min-height: 120px; }
.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; }
@@ -627,6 +696,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; }
+653 -52
View File
File diff suppressed because it is too large. Load diff
+117 -4
View File
@@ -12,7 +12,7 @@
<canvas id="bg"></canvas>
<header>
<div class="app-brand"><span class="obi"></span><strong>Stackchain</strong><span class="muted">Dashboard</span></div>
<div class="app-live-status"><span class="dot"></span><span class="small" id="status">Live</span></div>
<button class="app-live-status" id="open-live-data-status" type="button" aria-controls="live-data-status-sheet" aria-expanded="false"><span class="dot"></span><span class="small" id="status">Live</span></button>
<details class="app-menu">
<summary id="app-menu-toggle" aria-label="Open dashboard menu">Menu</summary>
<div class="app-menu-panel">
@@ -26,6 +26,20 @@
</div>
</details>
</header>
<div id="live-data-status-sheet" class="live-data-status-sheet" hidden>
<section class="live-data-status-panel" role="dialog" aria-modal="true" aria-labelledby="live-data-status-heading">
<div class="live-data-status-header">
<div><h2 id="live-data-status-heading">Live data status</h2><p class="small muted">Check which dashboard feeds are current before acting.</p></div>
<button id="close-live-data-status" type="button" aria-label="Close live data status">Close</button>
</div>
<div id="live-data-status-feeds" class="live-data-status-feeds"></div>
<p id="live-data-status-retry" class="small muted"></p>
<div class="live-data-status-actions">
<button id="refresh-live-data" type="button">Refresh now</button>
<span id="live-data-status-result" class="small" role="status" aria-live="polite"></span>
</div>
</section>
</div>
<div class="offline-status" id="offline-status" role="status" aria-live="polite" hidden>
Offline · live Gitea data is unavailable. Saved drafts remain available on this device.
</div>
@@ -82,6 +96,16 @@
<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>
<label for="device-setup-deadline-days">Warn me</label>
<select id="device-setup-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></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 +133,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 +153,10 @@
<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>
<label for="push-deadline-days">Warn me <select id="push-deadline-days" aria-label="Deadline reminder horizon"><option value="0">Due today</option><option value="2" selected>Next 2 days</option><option value="7">Next 7 days</option></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>
@@ -169,6 +198,10 @@
<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">
@@ -178,6 +211,41 @@
<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>
@@ -528,12 +596,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>
@@ -649,8 +749,9 @@
<div class="small" id="update-sheet-key"></div>
<h3 id="update-sheet-title">Unread update</h3>
</div>
<button id="keep-update-unread" type="button">Keep unread</button>
<button id="keep-update-unread" type="button">Keep unread &amp; next</button>
</div>
<div id="update-triage-progress" class="update-triage-progress small" aria-live="polite" hidden></div>
<div id="update-sheet-status" class="small" aria-live="polite">Choose an update.</div>
<button class="update-retry" id="retry-update-load" type="button" hidden>Retry loading update</button>
<div class="row">
@@ -693,6 +794,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="mute-update-next" type="button" hidden>Mute future updates &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>
@@ -885,10 +987,12 @@
<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>
@@ -899,7 +1003,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: no active queues; no upcoming deadlines"><span>Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0 active</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>
@@ -913,6 +1017,7 @@
<script src="static/widgets.js"></script>
<script src="static/drafts.js"></script>
<script src="static/unfiled-captures.js"></script>
<script src="static/shared-image-capture.js"></script>
<script src="static/draft-filing-session.js"></script>
<script src="static/draft-capacity-dialog.js"></script>
<script src="static/outbox-coordinator.js"></script>
@@ -925,6 +1030,8 @@
<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>
@@ -948,6 +1055,7 @@
<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>
@@ -961,10 +1069,15 @@
<script src="static/work-route.js"></script>
<script src="static/task-overlay-history.js"></script>
<script src="static/context-poller.js"></script>
<script src="static/live-data-status.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/update-triage-session.js"></script>
<script src="static/update-triage-launcher.js"></script>
<script src="static/agenda-session-launcher.js"></script>
<script src="static/mobile-launch.js"></script>
<script src="static/mobile-app-shortcuts.js"></script>
<script src="static/install-app.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
+78
View File
@@ -0,0 +1,78 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else root.liveDataStatus = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const feeds = [
['context', 'Work'],
['notifications', 'Updates'],
['events', 'Activity'],
];
function boundedSeconds(value) {
const seconds = Number(value);
return Number.isFinite(seconds) && seconds >= 0 ? Math.min(Math.round(seconds), 86400) : null;
}
function describe(freshness = {}) {
const sections = freshness.sections || {};
const hasSectionData = feeds.some(([key]) => Object.prototype.hasOwnProperty.call(sections, key));
const described = feeds.map(([key, label]) => {
const section = sections[key] || {};
const state = section.revalidating ? 'refreshing' :
(section.stale || section.degraded ? 'delayed' : 'live');
return { key, label, state, ageSeconds: boundedSeconds(section.age_seconds) };
});
const delayed = described.filter(feed => feed.state === 'delayed');
const refreshing = described.filter(feed => feed.state === 'refreshing');
let summary = hasSectionData ? 'Live' : 'Live data unavailable';
if (delayed.length === 1) summary = delayed[0].label + ' delayed';
else if (delayed.length > 1) summary = delayed.length + ' data feeds delayed';
else if (refreshing.length === 1) summary = refreshing[0].label + ' refreshing';
else if (refreshing.length > 1) summary = 'Refreshing live data';
const retryValues = described.map(feed => {
const section = sections[feed.key] || {};
return boundedSeconds(section.retry_in_seconds);
}).filter(value => value !== null && value > 0);
const aggregateRetry = boundedSeconds(freshness.retry_in_seconds);
if (aggregateRetry !== null && aggregateRetry > 0) retryValues.push(aggregateRetry);
return {
summary,
feeds: described,
nextRetrySeconds: retryValues.length ? Math.min(...retryValues) : null,
};
}
function createRefreshController({ button, output, refresh, onState = () => {} }) {
let pending = null;
function run() {
if (pending) return pending;
button.disabled = true;
output.textContent = 'Refreshing live data…';
onState('refreshing');
let request;
try {
request = refresh();
} catch (error) {
request = Promise.reject(error);
}
pending = Promise.resolve(request).then(result => {
if (!result) throw new Error('Live data remains unavailable.');
output.textContent = 'Live data refreshed.';
onState('success');
return result;
}).catch(error => {
output.textContent = error && error.message ? error.message : 'Live data refresh failed.';
onState('error');
return null;
}).finally(() => {
button.disabled = false;
pending = null;
});
return pending;
}
return { run };
}
return { describe, createRefreshController };
});
+6 -1
View File
@@ -4,7 +4,7 @@
}(typeof self !== 'undefined' ? self : this, function createLoginController(options) {
function validShareContinuation(value) {
if (typeof value !== 'string' || !value.startsWith('./?') || value.includes('#')) return './';
const limits = { title: 200, text: 8000, url: 2048 };
const limits = { title: 200, text: 8000, url: 2048, launch: 8, shared: 5 };
const params = new URLSearchParams(value.slice(3));
const entries = Array.from(params.entries());
if (!entries.length) return './';
@@ -14,6 +14,11 @@
|| content.length > limits[name]
));
if (invalid) return './';
if (Object.keys(limits).some(name => params.getAll(name).length > 1)) return './';
const launch = params.get('launch');
const shared = params.get('shared');
if (launch && !['continue', 'new', 'agenda'].includes(launch)) return './';
if (shared !== null && (shared !== 'image' || launch !== 'new')) return './';
return value;
}
+31 -3
View File
@@ -12,10 +12,38 @@
{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "static/icons/stackchain-512.png", "sizes": "512x512", "type": "image/png"}
],
"shortcuts": [
{
"name": "Continue work",
"short_name": "Continue",
"description": "Resume the highest-priority mobile work flow.",
"url": "./?launch=continue",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "New issue",
"short_name": "New",
"description": "Capture work now and file it online or offline.",
"url": "./?launch=new",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
},
{
"name": "Open Agenda",
"short_name": "Agenda",
"description": "Check every assigned deadline and continue the Agenda session.",
"url": "./?launch=agenda",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}]
}
],
"share_target": {
"action": "./",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"}
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}]
}
}
}
+31
View File
@@ -0,0 +1,31 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.mobileAppShortcuts = api;
})(typeof self !== 'undefined' ? self : this, function () {
const ACTIONS = new Set(['continue', 'new', 'agenda']);
function parse(search) {
const action = new URLSearchParams(search || '').get('launch');
return ACTIONS.has(action) ? action : null;
}
function createController(options) {
const launchAction = parse(options.search);
let handled = false;
function action() { return launchAction; }
function run() {
if (!launchAction || handled) return null;
handled = true;
if (launchAction === 'continue') return options.continueWork();
if (launchAction === 'new') return options.newIssue();
return options.agenda();
}
return {action, run};
}
return {parse, createController};
});
+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';
}
+29 -1
View File
@@ -8,9 +8,28 @@
later: 'No deferred work is ready to open.',
draft: 'No drafts are ready to open.',
};
const continuation = [
['attention', 'Start Attention'],
['today', 'Continue Today'],
['update', 'Resume Updates'],
['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();
if (name === 'agenda') return options.openAgenda();
if (name === 'update' && options.openUpdates) return options.openUpdates();
options.selectFilter(name);
const action = options.firstAction(name);
if (!action) {
@@ -21,5 +40,14 @@
return 'opened';
}
return { open };
function continueWork() {
const next = recommend();
if (next.name === 'find') {
options.openFindWork();
return 'find';
}
return open(next.name);
}
return { open, recommend, continueWork };
});
+32 -17
View File
@@ -64,18 +64,15 @@
}
function updateWork(mode) {
const labels = {
continue: ['Continue', 'Continue Today'],
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
if (options.workLabel) options.workLabel.textContent = labels[workMode][0];
if (buttons.work) {
buttons.work.setAttribute('aria-label', labels[workMode][1]);
}
const text = {
continue:'Continue', resume:'Resume', start:'Start', plan:'Plan', find:'Find',
attention:'Attention', update:'Updates', agenda:'Agenda', later:'Later', draft:'Drafts',
}[mode] || 'Work';
const queue = ['Attention', 'Updates', 'Agenda', 'Later', 'Drafts'].includes(text);
if (options.workLabel) options.workLabel.textContent = text;
if (buttons.work) buttons.work.setAttribute('aria-label', queue
? (mode === 'update' ? 'Resume ' : 'Open ') + text
: mode === 'find' ? 'Find work' : text + (mode === 'work' ? '' : ' Today'));
}
function updateAttention(count) {
@@ -94,9 +91,10 @@
}
function updateQueues(counts) {
const names = 'today attention update later draft'.split(' ');
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 = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
const actionableNames = ['today', 'attention', 'update', 'later', 'draft'];
const active = actionableNames.reduce((total, name) => total + (normalized[name] > 0 ? 1 : 0), 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
element.textContent = String(normalized[name] || 0);
});
@@ -104,10 +102,27 @@
'aria-label', 'Updates, ' + normalized.update + ' unread conversations'
);
if (options.queueBadge) {
options.queueBadge.textContent = String(total);
options.queueBadge.hidden = total === 0;
options.queueBadge.textContent = active + ' active';
options.queueBadge.hidden = active === 0;
}
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 label = active === 0 && normalized.agenda === 0
? 'Queues: no active queues; no upcoming deadlines'
: 'Queues: Today ' + normalized.today
+ ', Agenda ' + normalized.agenda + ' due'
+ ', Attention ' + normalized.attention
+ ', Updates ' + normalized.update
+ ', Later ' + normalized.later
+ ', Drafts ' + normalized.draft
+ '; ' + active + ' active ' + (active === 1 ? 'queue' : 'queues');
buttons.queues?.setAttribute('aria-label', label);
}
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
+5 -2
View File
@@ -4,10 +4,12 @@
})(typeof self !== 'undefined' ? self : this, function createMobileWorkEntry(options) {
function mode() {
if (options.isTodayActive()) return 'continue';
const recommended = options.queueLauncher && options.queueLauncher.recommend();
if (recommended && recommended.name !== 'today') return recommended.name;
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 +18,8 @@
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.openFallback();
else if (current === 'find') options.findWork();
else options.queueLauncher.open(current);
return current;
}
+113 -25
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,
};
}
@@ -237,7 +248,8 @@ function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
let pagination = { page: 1, total: 0, has_more: false };
let pending = false;
return {
let completing = null;
const pager = {
reset(next) {
pagination = { ...pagination, ...(next || {}) };
onPagination(pagination);
@@ -271,18 +283,31 @@ function createNotificationPager({ load, onNotifications, onPagination, onStatus
pending = false;
}
},
loadAll(getExisting) {
if (completing) return completing;
completing = (async () => {
while (pagination.has_more) {
const loaded = await pager.loadMore(getExisting());
if (!loaded) return false;
}
return true;
})().finally(() => { completing = null; });
return completing;
},
};
return pager;
}
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 +356,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({
@@ -342,12 +380,27 @@ function createNotificationReader({
loadConversation = null,
onConversation = () => {},
createPager = typeof createConversationPager === 'function' ? createConversationPager : null,
getScope = () => '',
}) {
let selected = null;
let loadVersion = 0;
let marking = false;
let conversationPager = null;
let offlineHydrated = false;
let prefetched = null;
let prefetchKey = '';
function prefetch(item) {
if (!item) { prefetched = null; prefetchKey = ''; return false; }
const notificationId = item?.notification_id;
if (!Number.isInteger(notificationId) || offlineHydrated) return false;
const key = getScope() + notificationId;
if (prefetchKey === key) return prefetched;
if (prefetched) return false;
prefetchKey = key;
prefetched = load(notificationId);
return prefetched;
}
async function advanceAfterRead(items, current, queueing = false) {
const updated = acknowledgeNotification(items, current.notification_id);
@@ -369,10 +422,19 @@ function createNotificationReader({
selected = item;
offlineHydrated = Boolean(savedDetail && !Array.isArray(savedDetail));
const version = ++loadVersion;
const preload = !offlineHydrated && prefetchKey === getScope() + item.notification_id ? prefetched : null;
prefetched = null;
prefetchKey = '';
onOpen(item);
onStatus('Loading update…');
if (!preload) onStatus('Loading update…');
try {
const detail = offlineHydrated ? savedDetail : await load(item.notification_id);
let detail;
if (offlineHydrated) detail = savedDetail;
else if (preload) detail = await preload.catch(() => {
onStatus('Loading update…');
return load(item.notification_id);
});
else detail = await load(item.notification_id);
if (selected !== item || version !== loadVersion) return false;
onDetail(detail);
if (createPager && loadConversation && detail.conversation) {
@@ -395,6 +457,7 @@ function createNotificationReader({
return {
open,
prefetch,
commentPager() {
return conversationPager;
},
@@ -478,7 +541,7 @@ function createNotificationReplier({
if ((storage.getItem(keyFor(item)) || '') !== body) storage.removeItem(operationKeyFor(item));
storage.setItem(keyFor(item), body);
}
catch (_error) { /* Keep the editable textarea as the fallback. */ }
catch (_error) {}
},
async submit(item, body, attachment = null) {
if (pending) return false;
@@ -504,15 +567,15 @@ function createNotificationReplier({
return { queued:true };
}
try { storage.removeItem(keyFor(item)); storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* Confirmed delivery is authoritative. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return delivery.confirmed[0];
}
const result = await post(item.notification_id, body, operationId);
try { storage.removeItem(keyFor(item)); }
catch (_error) { /* The posted reply is still authoritative. */ }
catch (_error) {}
try { storage.removeItem(operationKeyFor(item)); }
catch (_error) { /* A confirmed result no longer needs replay identity. */ }
catch (_error) {}
onStatus('Reply posted. You can mark this update read when ready.');
return result;
} catch (error) {
@@ -553,6 +616,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 => {
@@ -627,13 +713,14 @@ function createWorkSessionCheckpoint({
function createWorkSession({
getItems, getFilter, getMilestone = () => 'all', checkpoint = null,
checkpointEnabled = () => true,
checkpointEnabled = () => true, checkpointedEnabled = checkpointEnabled,
onOpen, onProgress, onFinish,
}) {
let currentIdentity = '';
let currentIndex = -1;
let running = false;
let durable = false;
const activeCheckpoint = () => typeof checkpoint === 'function' ? checkpoint() : checkpoint;
const queue = () => filterMyWork(getItems() || [], getFilter(), getMilestone());
const report = (items, index) => onProgress({
@@ -648,7 +735,7 @@ function createWorkSession({
durable = false;
currentIdentity = '';
currentIndex = -1;
if (clearCheckpoint) checkpoint?.clear();
if (clearCheckpoint) activeCheckpoint()?.clear();
onFinish();
return false;
};
@@ -656,7 +743,7 @@ function createWorkSession({
if (!items.length || index < 0 || index >= items.length) return finish();
currentIndex = index;
currentIdentity = workIdentity(items[index]);
if (durable) checkpoint?.save(currentIdentity, currentIndex);
if (durable) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
@@ -664,9 +751,9 @@ function createWorkSession({
return {
active: () => running,
checkpointed: item => running && durable && (!item || workIdentity(item) === currentIdentity),
checkpointed: item => running && durable && checkpointedEnabled() && (!item || workIdentity(item) === currentIdentity),
end: () => finish(),
resumable: () => Boolean(checkpoint?.read()),
resumable: () => Boolean(activeCheckpoint()?.read()),
reopen(requested = null) {
if (!running) return false;
const items = queue();
@@ -675,13 +762,13 @@ function createWorkSession({
if (index < 0) return false;
currentIdentity = requestedIdentity;
currentIndex = index;
if (durable && requested) checkpoint?.save(currentIdentity, currentIndex);
if (durable && requested) activeCheckpoint()?.save(currentIdentity, currentIndex);
report(items, index);
onOpen(items[index]);
return true;
},
resume(requested = null) {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return false;
durable = true;
const items = queue();
@@ -695,7 +782,7 @@ function createWorkSession({
const items = queue();
if (!items.length) return finish();
running = true;
durable = Boolean(checkpoint && checkpointEnabled());
durable = Boolean(activeCheckpoint() && checkpointEnabled());
const requested = item ? items.findIndex(candidate => workIdentity(candidate) === workIdentity(item)) : 0;
return openAt(items, requested >= 0 ? requested : 0);
},
@@ -743,7 +830,7 @@ function createWorkSession({
if (!items.length) return null;
if (action === 'start') return items[0];
if (action === 'resume') {
const saved = checkpoint?.read();
const saved = activeCheckpoint()?.read();
if (!saved) return null;
const exact = items.findIndex(item => workIdentity(item) === saved.identity);
return items[exact >= 0 ? exact : Math.min(saved.index, items.length - 1)];
@@ -837,6 +924,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;
+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;
+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;
+84 -4
View File
@@ -2,10 +2,29 @@
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, deadlineDays, notification, serviceWorker, fetchJson,
}) {
let configuration = null;
function formattedHour(value) {
return `${String(Number(value)).padStart(2, '0')}:00`;
}
function enabledDeadlineText(hour, days) {
const value = Number(days ?? 2);
const horizon = value === 0 ? 'due today' : `next ${value} days`;
return `Deadline reminders enabled for ${formattedHour(hour)} local time, ${horizon}.`;
}
function deadlineReadiness() {
if (!configuration?.available || deadlineControl?.disabled) {
return {state:'unavailable', detail:deadlineStatus?.textContent || 'Deadline reminders are unavailable.'};
}
return configuration.deadline_enabled
? {state:'complete', detail:enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)}
: {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 +39,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 +66,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 +87,73 @@
}
}
async function changeDeadline() {
deadlineControl.disabled = true;
if (deadlineHour) deadlineHour.disabled = true;
if (deadlineDays) deadlineDays.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);
const reminderDays = Number(deadlineDays?.value ?? configuration?.reminder_days ?? 2);
await fetchJson('api/v1/push-subscription/deadlines', {
method:'PUT',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({enabled:deadlineControl.checked, timezone, reminder_hour:reminderHour, reminder_days:reminderDays}),
});
configuration.deadline_enabled = deadlineControl.checked;
configuration.reminder_hour = reminderHour;
configuration.reminder_days = reminderDays;
configuration.timezone = timezone;
deadlineStatus.textContent = deadlineControl.checked
? enabledDeadlineText(reminderHour, reminderDays)
: '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;
if (deadlineDays) deadlineDays.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);
if (deadlineDays) deadlineDays.value = String(configuration.reminder_days ?? 2);
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
? enabledDeadlineText(configuration.reminder_hour, configuration.reminder_days)
: 'Deadline reminders are off for this device.';
}
return {init, change};
return {init, change, changeDeadline, enableDeadline, deadlineReadiness};
});
+96 -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-v97';
const CACHE = 'stackchain-dashboard-shell-v100';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
@@ -21,6 +21,7 @@ const SHELL = [
BASE + 'static/widgets.js',
BASE + 'static/drafts.js',
BASE + 'static/unfiled-captures.js',
BASE + 'static/shared-image-capture.js',
BASE + 'static/draft-filing-session.js',
BASE + 'static/draft-capacity-dialog.js',
BASE + 'static/outbox-coordinator.js',
@@ -32,6 +33,8 @@ 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',
@@ -55,6 +58,7 @@ const SHELL = [
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',
@@ -68,10 +72,15 @@ const SHELL = [
BASE + 'static/work-route.js',
BASE + 'static/task-overlay-history.js',
BASE + 'static/context-poller.js',
BASE + 'static/live-data-status.js',
BASE + 'static/mobile-task-dock.js',
BASE + 'static/mobile-work-entry.js',
BASE + 'static/mobile-queue-launcher.js',
BASE + 'static/update-triage-session.js',
BASE + 'static/update-triage-launcher.js',
BASE + 'static/agenda-session-launcher.js',
BASE + 'static/mobile-launch.js',
BASE + 'static/mobile-app-shortcuts.js',
BASE + 'static/install-app.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
@@ -83,6 +92,44 @@ const SHELL = [
const OPTIONAL_FEATURES = [
];
const SHARED_IMAGE_ID = 'shared-image';
const SHARED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
const MAX_SHARED_IMAGE_BYTES = 12 * 1024 * 1024;
const sharedAttachmentStore = self.__STACKCHAIN_SHARED_ATTACHMENT_STORE || createUnfiledAttachmentStore();
function boundedShareField(form, name, limit) {
const value = form.get(name);
return typeof value === 'string' ? value.trim().slice(0, limit) : '';
}
async function acceptSharedContent(request) {
const form = await request.formData();
const images = form.getAll('image').filter(value => typeof value !== 'string' && value?.size > 0);
let marker = '';
await sharedAttachmentStore.delete(SHARED_IMAGE_ID).catch(() => {});
if (images.length > 1) marker = 'multiple';
else if (images.length === 1) {
const image = images[0];
if (!SHARED_IMAGE_TYPES.has(String(image.type || '')) || image.size > MAX_SHARED_IMAGE_BYTES) {
marker = 'unsupported';
} else {
await sharedAttachmentStore.put(SHARED_IMAGE_ID, {
filename:String(image.name || 'shared-screenshot').slice(0, 255),
contentType:String(image.type), blob:image,
});
marker = 'image';
}
}
const target = new URL(BASE, self.location.origin);
target.searchParams.set('launch', 'new');
if (marker) target.searchParams.set('shared', marker);
for (const [name, limit] of [['title', 255], ['text', 10000], ['url', 2048]]) {
const value = boundedShareField(form, name, limit);
if (value) target.searchParams.set(name, value);
}
return new Response(null, {status:303, headers:{Location:target.pathname + target.search}});
}
async function fetchNavigation(request) {
const controller = new AbortController();
let timeout;
@@ -311,9 +358,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'
@@ -359,6 +430,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 (
@@ -419,6 +506,14 @@ self.addEventListener('notificationclick', event => {
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
if (
request.method === 'POST' && request.mode === 'navigate'
&& requestUrl.origin === self.location.origin && requestUrl.pathname === BASE
) {
event.respondWith(acceptSharedContent(request));
return;
}
if (request.method !== 'GET' || request.url.includes('/api/')) return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return;
+29
View File
@@ -0,0 +1,29 @@
(function(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
else root.sharedImageCapture = api;
})(typeof self !== 'undefined' ? self : this, function() {
'use strict';
const RECORD_ID = 'shared-image';
const TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
async function consume({marker, store, restore, status = () => {}}) {
if (!marker) return false;
if (marker !== 'image') {
status('Share one PNG, JPEG, or WebP screenshot.');
return false;
}
const value = await store?.get(RECORD_ID);
if (!value?.blob || !value.filename || !TYPES.has(String(value.contentType || ''))) {
status('The shared screenshot is unavailable. Share it again.');
return false;
}
restore(value);
await store.delete(RECORD_ID);
status('Shared screenshot ready to file with this issue.');
return true;
}
return {consume, RECORD_ID};
});
+39
View File
@@ -0,0 +1,39 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageLauncher = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageLauncher(options) {
let pending = null;
function open() {
if (pending) return pending;
options.selectUpdates();
options.announce('Checking all unread updates…');
pending = Promise.resolve()
.then(() => options.discover())
.then(discovery => {
if (!options.isUpdatesSelected()) return 'cancelled';
if (discovery === false || discovery?.complete === false) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
if (discovery?.complete) options.applySnapshot?.(discovery);
if (options.hasMore()) {
options.announce('Updates check paused. Retry to check older unread updates.');
return 'incomplete';
}
const opened = options.hasCheckpoint() ? options.resume() : options.start();
if (!opened) options.announce('No unread updates are ready.');
return opened ? 'opened' : 'empty';
})
.catch(() => {
if (options.isUpdatesSelected()) {
options.announce('Updates check paused. Retry to check older unread updates.');
}
return 'incomplete';
})
.finally(() => { pending = null; });
return pending;
}
return {open};
});
+107
View File
@@ -0,0 +1,107 @@
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createUpdateTriageSession = factory;
})(typeof self !== 'undefined' ? self : this, function createUpdateTriageSession(options) {
const key = options.key || 'stackchain.update-triage.v1';
const identity = item => Number.isInteger(item?.notification_id) ? String(item.notification_id) : '';
const login = () => String(options.getLogin() || '').trim();
let state = null;
let running = false;
function read() {
const owner = login();
if (!owner) return null;
try {
const value = JSON.parse(options.storage.getItem(key) || 'null');
if (value?.version !== 1 || value.login !== owner || !Array.isArray(value.identities) ||
!value.identities.length || typeof value.current !== 'string' || !Array.isArray(value.completed)) return null;
const identities = value.identities.filter(value => typeof value === 'string' && value);
const completed = value.completed.filter(value => identities.includes(value));
if (!identities.length || !identities.includes(value.current)) return null;
return { version:1, login:owner, identities, current:value.current, completed };
} catch (_) {
return null;
}
}
function persist() {
if (!state) return;
options.storage.setItem(key, JSON.stringify(state));
}
function available() {
const byIdentity = new Map((options.getItems() || []).map(item => [identity(item), item]));
return state.identities.filter(id => !state.completed.includes(id) && byIdentity.has(id))
.map(id => byIdentity.get(id));
}
function nextAvailable() {
if (!running || !state) return null;
const candidates = available().filter(item => identity(item) !== state.current);
return candidates.find(item => state.identities.indexOf(identity(item)) > state.identities.indexOf(state.current)) || candidates[0] || null;
}
function finish() {
running = false;
state = null;
options.storage.removeItem(key);
options.onFinish();
return false;
}
function openCurrent(preferredIndex = null) {
const items = available();
if (!items.length) return finish();
let index = items.findIndex(item => identity(item) === state.current);
if (index < 0) index = Math.min(preferredIndex ?? 0, items.length - 1);
state.current = identity(items[index]);
persist();
const originalIndex = state.identities.indexOf(state.current);
options.onProgress({ index:originalIndex + 1, total:state.identities.length });
options.onOpen(items[index]);
return true;
}
function advance(openNext = true) {
if (!running || !state) return false;
const originalIndex = state.identities.indexOf(state.current);
if (!state.completed.includes(state.current)) state.completed.push(state.current);
const candidates = available();
const next = candidates.find(item => state.identities.indexOf(identity(item)) > originalIndex) || candidates[0];
if (!next) return finish();
state.current = identity(next);
if (openNext) return openCurrent();
persist();
options.onProgress({ index:state.identities.indexOf(state.current) + 1, total:state.identities.length });
return true;
}
return {
active: () => running,
resumable: () => Boolean(read()),
start() {
const identities = (options.getItems() || []).map(identity).filter(Boolean);
if (!identities.length) return finish();
state = { version:1, login:login(), identities, current:identities[0], completed:[] };
if (!state.login) return false;
running = true;
return openCurrent();
},
resume() {
state = read();
if (!state) return false;
running = true;
return openCurrent(state.identities.indexOf(state.current));
},
reconcile() {
if (!running || !state) return false;
return openCurrent(state.identities.indexOf(state.current));
},
completeAndNext: advance,
acceptCompleted: () => advance(false),
keepUnreadAndNext: advance,
next: nextAvailable,
items: () => state ? available().slice() : [],
end: finish,
};
});
+5 -2
View File
@@ -6,7 +6,7 @@
'use strict';
const repositoryPart = /^[A-Za-z0-9_.-]+$/;
const queueFilters = ['today', 'attention', 'update', 'later', 'draft'];
const queueFilters = ['today', 'agenda', 'attention', 'update', 'later', 'draft'];
function positiveInteger(value) {
const number = Number(value);
@@ -20,6 +20,9 @@
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;
@@ -114,7 +117,7 @@
if (active && active !== fragment) onClose();
if (active === fragment) return;
active = fragment;
onQueue(route.filter);
onQueue(route.filter, route.action || null);
return;
}
const item = items.find(candidate => sameRoute(candidate, route));
+25 -1
View File
@@ -52,6 +52,30 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
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); });
@@ -62,7 +86,7 @@ function createWorkSelection({ limit = 50, onChange = () => {} } = {}) {
return { active, count: selected.size, ids: Array.from(selected) };
}
return { identity, start, cancel, select, toggle, retain, snapshot, limit: maximum };
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,
+2 -2
View File
@@ -28,10 +28,10 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/update-triage-session.js", "static/update-triage-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/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
),
}
CACHE_DECLARATION = re.compile(
+69 -3
View File
@@ -4,7 +4,7 @@ import re
import shlex
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import urljoin, urlsplit
from urllib.parse import quote, urljoin, urlsplit
import httpx
@@ -403,6 +403,41 @@ 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,
max_concurrency: int = 4,
deadline_seconds: float = 5.0,
) -> dict:
"""Load a complete, bounded assigned-issue snapshot for deadline dispatch."""
async with asyncio.timeout(deadline_seconds):
first = await work_page("issue", 1, limit)
page_count = max(1, (first["total"] + limit - 1) // limit)
if page_count > max_pages:
raise ValueError("Assigned issue snapshot exceeds the scan limit")
semaphore = asyncio.Semaphore(max(1, max_concurrency))
async def load(page: int) -> dict:
async with semaphore:
return await work_page("issue", page, limit)
remaining = await asyncio.gather(
*(load(page) for page in range(2, page_count + 1))
)
pages = [first, *remaining]
if any(page.get("total") != first["total"] for page in pages[1:]):
raise ValueError("Assigned issue pagination changed during the scan")
items = [item for page in pages for item in page["items"]]
issue_ids = [
item.get("id") for item in items
if isinstance(item, dict) and isinstance(item.get("id"), int) and item["id"] > 0
]
if len(items) != first["total"] or len(set(issue_ids)) != first["total"]:
raise ValueError("Assigned issue snapshot has an incomplete issue set")
return {"items": items, "complete": True}
def _normalize_global_search_item(item: Any, kind: str) -> dict | None:
if not isinstance(item, dict):
return None
@@ -881,6 +916,14 @@ def _gitea_api_path(value: Any) -> str:
return parsed.path[len(prefix):] + (("?" + parsed.query) if parsed.query else "")
async def _notification_subscription(repository: str, number: str) -> dict:
try:
value = await fetch(f"repos/{repository}/issues/{number}/subscriptions/check")
except Exception:
return {}
return value if isinstance(value, dict) else {}
async def notification_detail(thread_id: int) -> dict:
thread = await fetch(f"notifications/threads/{thread_id}")
if not isinstance(thread, dict):
@@ -905,18 +948,20 @@ async def notification_detail(thread_id: int) -> dict:
)
if supported_conversation:
assert conversation_match is not None
subject_detail, comment, conversation = await asyncio.gather(
subject_detail, comment, conversation, subscription = await asyncio.gather(
fetch(subject_path),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
issue_conversation_page(
conversation_match.group(1), int(conversation_match.group(3))
),
_notification_subscription(conversation_match.group(1), conversation_match.group(3)),
)
else:
subject_detail, comment, conversation = await asyncio.gather(
subject_detail, comment, conversation, subscription = await asyncio.gather(
fetch(subject_path) if subject_path else asyncio.sleep(0, result={}),
fetch(comment_path) if comment_path else asyncio.sleep(0, result={}),
asyncio.sleep(0, result={"comments": [], "page": 1, "older_page": None, "total": 0}),
asyncio.sleep(0, result={}),
)
subject_detail = subject_detail if isinstance(subject_detail, dict) else {}
comment = comment if isinstance(comment, dict) else {}
@@ -977,6 +1022,12 @@ async def notification_detail(thread_id: int) -> dict:
and comment_match
and comment_match.group(1) == repository_name
),
"mute_supported": bool(
supported_conversation
and isinstance(subscription, dict)
and subscription.get("subscribed") is True
and subscription.get("ignored") is not True
),
"conversation": conversation,
}
@@ -1023,6 +1074,21 @@ async def notification_conversation_target(thread_id: int) -> tuple[str, int]:
return match.group(1), int(match.group(3))
async def mute_notification(thread_id: int) -> dict:
"""Unsubscribe the current operator from the trusted notification conversation."""
repository, number = await notification_conversation_target(thread_id)
user = await fetch("user")
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("Gitea did not identify the current operator")
response = await _get_client().delete(
f"/api/v1/repos/{repository}/issues/{number}/subscriptions/{quote(login, safe='')}",
headers=_auth(),
)
response.raise_for_status()
return {"id": thread_id, "repository": repository, "number": number, "muted": True}
async def reply_to_notification(thread_id: int, body: str) -> 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()
+302 -43
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,11 @@ 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
@@ -59,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()
@@ -79,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"))
)
@@ -95,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:
@@ -128,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:
@@ -145,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)
@@ -153,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
@@ -181,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")
@@ -276,6 +390,24 @@ 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)
reminder_days: Literal[0, 2, 7] = 2
@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",
@@ -880,6 +1012,17 @@ def _share_target_login_redirect(request: Request) -> str:
for name in limits
if request.query_params.get(name)
]
launch = request.query_params.get("launch", "")
if launch in {"continue", "new", "agenda"}:
shared.append(("launch", launch))
image_markers = request.query_params.getlist("shared")
if image_markers:
if (
image_markers != ["image"]
or request.query_params.getlist("launch") != ["new"]
):
return "login"
shared.append(("shared", "image"))
if dashboard_auth.application_path(request) != "/" or not shared:
return "login"
continuation = f"./?{urlencode(shared)}"
@@ -1798,10 +1941,17 @@ 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"],
"reminder_days": preferences["reminder_days"],
}
@@ -1855,6 +2005,31 @@ 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,
reminder_days=payload.reminder_days,
)
return {
"deadline_enabled": payload.enabled,
"timezone": payload.timezone,
"reminder_hour": payload.reminder_hour,
"reminder_days": payload.reminder_days,
}
@app.post("/api/v1/session/activity")
async def record_session_activity(request: Request):
session = request.state.dashboard_session
@@ -2358,43 +2533,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
@@ -2705,14 +2872,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:
@@ -2721,19 +2892,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
@@ -2744,7 +2949,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:
@@ -3385,6 +3592,21 @@ async def notification_page(page: int = Query(default=1, ge=1)) -> JSONResponse:
return JSONResponse(result)
@app.get("/api/v1/notifications/snapshot")
async def notification_snapshot() -> JSONResponse:
try:
result = await gitea_proxy.unread_notification_snapshot(
deadline_seconds=NOTIFICATION_PAGE_TIMEOUT_SECONDS
)
except Exception:
return JSONResponse(
{"error": "Complete unread updates are temporarily unavailable. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
return JSONResponse(result)
@app.get("/api/v1/notifications/{thread_id}")
async def notification_thread_detail(
thread_id: int = PathParam(gt=0),
@@ -3478,6 +3700,43 @@ async def unread_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
return JSONResponse({"id": thread_id, "status": "unread"})
@app.post("/api/v1/notifications/{thread_id}/mute")
async def mute_notification(thread_id: int = PathParam(gt=0)) -> JSONResponse:
try:
await asyncio.wait_for(
gitea_proxy.mute_notification(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except TimeoutError:
return JSONResponse(
{"error": "Muting future updates timed out. Please retry."},
status_code=503,
headers={"Retry-After": "1"},
)
except Exception:
return JSONResponse(
{"error": "Future updates could not be muted. Please retry."},
status_code=503,
)
try:
await asyncio.wait_for(
mark_notification_read(thread_id),
timeout=NOTIFICATION_MUTATION_TIMEOUT_SECONDS,
)
except Exception:
return JSONResponse(
{
"id": thread_id,
"muted": True,
"status": "unread",
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
},
status_code=409,
)
await _remove_notifications_from_live_snapshot([thread_id])
return JSONResponse({"id": thread_id, "muted": True, "status": "read"})
@app.patch("/api/v1/notifications/{thread_id}/later")
async def defer_notification(
payload: NotificationLaterRequest,
+173 -8
View File
@@ -3,7 +3,9 @@ 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
@@ -47,7 +49,7 @@ 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,
@@ -60,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,
)
@@ -76,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()
@@ -126,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,
)
@@ -172,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,
)
@@ -233,4 +240,162 @@ async def dispatch_unread_updates(
)
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=device.reminder_days)
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))
+110 -10
View File
@@ -22,6 +22,16 @@ 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
reminder_days: int
delivered_local_day: str | None
def _revisions(
values: Mapping[int, str] | Iterable[int | tuple[int, str]],
) -> tuple[tuple[int, str], ...]:
@@ -66,7 +76,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 +88,16 @@ 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,
reminder_days INTEGER NOT NULL DEFAULT 2,
delivered_local_day TEXT,
FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id)
ON DELETE CASCADE
);
"""
)
delivery_columns = {
@@ -95,6 +115,32 @@ 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;
"""
)
preference_columns = {
row[1]
for row in connection.execute("PRAGMA table_info(push_deadline_preferences)")
}
if "reminder_days" not in preference_columns:
connection.execute(
"ALTER TABLE push_deadline_preferences ADD COLUMN reminder_days INTEGER NOT NULL DEFAULT 2"
)
os.chmod(self.path, 0o600)
def _connect(self):
@@ -103,29 +149,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 +201,59 @@ 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,
reminder_days: int = 2,
) -> None:
with self._connect() as connection:
connection.execute(
"""INSERT INTO push_deadline_preferences(
session_id, enabled, timezone, reminder_hour, reminder_days
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
enabled = excluded.enabled,
timezone = excluded.timezone,
reminder_hour = excluded.reminder_hour,
reminder_days = excluded.reminder_days""",
(session_id, int(enabled), timezone, reminder_hour, reminder_days),
)
def deadline_preferences(self, session_id: str) -> dict:
with self._connect() as connection:
row = connection.execute(
"""SELECT enabled, timezone, reminder_hour, reminder_days
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,
"reminder_days": row[3] if row else 2,
}
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.reminder_days, 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], row[5])
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()
+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-v97" in worker
assert "stackchain-dashboard-shell-v100" in worker
+50
View File
@@ -876,6 +876,55 @@ async def test_anonymous_share_target_redirect_preserves_only_bounded_capture_fi
assert response.headers["cache-control"] == "no-store"
@pytest.mark.anyio
async def test_anonymous_app_shortcut_preserves_only_a_known_launch_action(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get("/", params={"launch": "agenda", "next": "https://evil.example"})
invalid = await client.get("/", params={"launch": "delete-account"})
assert valid.status_code == 303
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": ["./?launch=agenda"]
}
assert invalid.status_code == 303
assert invalid.headers["location"] == "login"
@pytest.mark.anyio
async def test_anonymous_shared_screenshot_preserves_bounded_sign_in_continuation(access_control):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
valid = await client.get(
"/",
params={
"launch": "new",
"shared": "image",
"title": "Broken checkout",
"text": "Steps to reproduce",
},
)
wrong_launch = await client.get(
"/", params={"launch": "agenda", "shared": "image"}
)
wrong_marker = await client.get(
"/", params={"launch": "new", "shared": "document"}
)
duplicate_marker = await client.get(
"/?launch=new&shared=image&shared=image"
)
assert valid.status_code == 303
assert parse_qs(urlsplit(valid.headers["location"]).query) == {
"continue": [
"./?title=Broken+checkout&text=Steps+to+reproduce&launch=new&shared=image"
]
}
assert wrong_launch.headers["location"] == "login"
assert wrong_marker.headers["location"] == "login"
assert duplicate_marker.headers["location"] == "login"
@pytest.mark.anyio
async def test_oversized_share_target_is_not_carried_through_login(access_control):
transport = httpx.ASGITransport(app=main.app)
@@ -2046,6 +2095,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")
+479
View File
@@ -0,0 +1,479 @@
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_uses_each_devices_confirmed_horizon(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for device, reminder_days in (("today", 0), ("week", 7)):
store.upsert(device, {
"endpoint": f"https://push.example/{device}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_deadline_preferences(
device,
enabled=True,
timezone="UTC",
reminder_hour=9,
reminder_days=reminder_days,
)
async def assigned():
return {"complete": True, "items": [
{"id": 1, "due_date": "2026-08-13"},
{"id": 2, "due_date": "2026-08-15"},
{"id": 3, "due_date": "2026-08-20"},
{"id": 4, "due_date": "2026-08-21"},
]}
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, 10, 0, tzinfo=timezone.utc),
)
assert delivered == 2
assert sent == {"today": 1, "week": 3}
@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,
"reminder_days": 2,
}
def test_existing_deadline_preferences_migrate_to_two_day_horizon(tmp_path):
database = tmp_path / "push.sqlite3"
import sqlite3
with sqlite3.connect(database) as connection:
connection.executescript("""
CREATE TABLE push_subscriptions (
session_id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
subscription_json TEXT NOT NULL
);
CREATE TABLE push_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
);
INSERT INTO push_subscriptions VALUES ('device-a', 'https://push.example/a', '{}');
INSERT INTO push_deadline_preferences VALUES ('device-a', 1, 'UTC', 8, NULL);
""")
store = PushSubscriptionStore(database)
assert store.deadline_preferences("device-a")["reminder_days"] == 2
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_is_pagination_complete(monkeypatch):
pages = {
1: {"items": [{"id": 1}], "total": 2, "has_more": True},
2: {"items": [{"id": 2}], "total": 2, "has_more": False},
}
async def work_page(stream, page, limit):
assert stream == "issue"
assert limit == 1
return pages[page]
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
assert await gitea_proxy.assigned_issue_snapshot(limit=1) == {
"items": [{"id": 1}, {"id": 2}],
"complete": True,
}
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_fetches_remaining_pages_concurrently(monkeypatch):
active = 0
peak = 0
remaining_started = asyncio.Event()
async def work_page(stream, page, limit):
nonlocal active, peak
assert stream == "issue"
assert limit == 1
if page == 1:
return {"items": [{"id": 1}], "total": 5, "has_more": True}
active += 1
peak = max(peak, active)
if active == 2:
remaining_started.set()
await asyncio.wait_for(remaining_started.wait(), timeout=0.2)
await asyncio.sleep(0)
active -= 1
return {"items": [{"id": page}], "total": 5, "has_more": page < 5}
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
assert await gitea_proxy.assigned_issue_snapshot(limit=1, max_concurrency=2) == {
"items": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}, {"id": 5}],
"complete": True,
}
assert peak == 2
@pytest.mark.anyio
@pytest.mark.parametrize(
"remaining",
[
{"items": [{"id": 2}], "total": 3, "has_more": True},
{"items": [{"id": 1}], "total": 2, "has_more": False},
],
)
async def test_assigned_deadline_snapshot_rejects_changed_or_duplicate_pages(
monkeypatch, remaining
):
async def work_page(_stream, page, _limit):
if page == 1:
return {"items": [{"id": 1}], "total": 2, "has_more": True}
return remaining
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
with pytest.raises(ValueError, match="incomplete|changed"):
await gitea_proxy.assigned_issue_snapshot(limit=1)
@pytest.mark.anyio
async def test_assigned_deadline_snapshot_enforces_aggregate_deadline(monkeypatch):
cancelled = asyncio.Event()
async def work_page(_stream, _page, _limit):
try:
await asyncio.sleep(60)
finally:
cancelled.set()
monkeypatch.setattr(gitea_proxy, "work_page", work_page)
with pytest.raises(TimeoutError):
await gitea_proxy.assigned_issue_snapshot(deadline_seconds=0.01)
assert cancelled.is_set()
@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
+3 -3
View File
@@ -68,8 +68,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
assert b"function attachSecurityCenter" in security_center.runtime_bytes
assert b"gitea_time_logged" not in first.runtime_bytes
assert b"gitea_time_logged" in security_center.runtime_bytes
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
assert len(first.runtime_gzip_bytes) <= 96 * 1024
# One-ahead Updates prefetch stays in the core reader so transitions can reuse its in-flight request.
assert len(first.runtime_gzip_bytes) <= 97 * 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-v97';",
"const CACHE = 'stackchain-dashboard-shell-v100';",
"const CACHE = 'stackchain-dashboard-shell-v999';",
)
)
+38
View File
@@ -254,6 +254,8 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"number": 7, "state": "open", "assignees": [],
"body": "Deploy fails after **three** retries.",
})
if request.url.path.endswith("/issues/7/subscriptions/check"):
return httpx.Response(200, json={"subscribed": True, "ignored": False})
if request.url.path.endswith("/issues/comments/9"):
return httpx.Response(200, json={
"id": 9,
@@ -283,6 +285,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/comments/9",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/comments?limit=20&page=1",
"http://127.0.0.1:3000/api/v1/repos/stackchain/api/issues/7/subscriptions/check",
]
assert result == {
"id": 42,
@@ -300,6 +303,7 @@ async def test_notification_detail_loads_subject_and_latest_comment_for_inbox_re
},
"issue": {"number": 7, "assignees": [], "claimable": True},
"acknowledge_supported": True,
"mute_supported": True,
"conversation": {
"comments": [{
"id": 9,
@@ -398,3 +402,37 @@ async def test_reply_to_notification_posts_to_its_issue_conversation(subject_kin
"created_at": "2026-08-07T19:00:00Z",
"url": "https://forge.example/stackchain/api/issues/7#issuecomment-91",
}
@pytest.mark.anyio
async def test_mute_notification_unsubscribes_current_user_from_trusted_thread_target():
requests = []
def upstream(request):
requests.append((request.method, request.url.path))
if request.url.path.endswith("/notifications/threads/42"):
return httpx.Response(200, json={
"repository": {"full_name": "stackchain/api"},
"subject": {
"type": "Pull",
"url": "http://127.0.0.1:3000/api/v1/repos/stackchain/api/pulls/7",
},
})
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"login": "timmy"})
if request.method == "DELETE":
return httpx.Response(204)
return httpx.Response(404)
gitea_proxy.start_client(transport=httpx.MockTransport(upstream))
try:
result = await gitea_proxy.mute_notification(42)
finally:
await gitea_proxy.stop_client()
assert result == {"id": 42, "repository": "stackchain/api", "number": 7, "muted": True}
assert requests == [
("GET", "/api/v1/notifications/threads/42"),
("GET", "/api/v1/user"),
("DELETE", "/api/v1/repos/stackchain/api/issues/7/subscriptions/timmy"),
]
+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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/later-sync.js'" in source
+101
View File
@@ -0,0 +1,101 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
STATUS = ROOT / "frontend" / "live-data-status.js"
HTML = ROOT / "frontend" / "index.html"
CSS = ROOT / "frontend" / "dashboard.css"
DASHBOARD = ROOT / "frontend" / "dashboard.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_live_data_status_summarizes_each_feed_without_claiming_live():
script = f"""
const status = require({json.dumps(str(STATUS))});
const healthy = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:2}}, events:{{age_seconds:3}}
}}}};
const oneDelayed = {{fresh_for_seconds:8, retry_in_seconds:30, sections:{{
context:{{age_seconds:1}}, notifications:{{age_seconds:14, stale:true, retry_in_seconds:30}}, events:{{age_seconds:3}}
}}}};
const twoDelayed = {{fresh_for_seconds:8, sections:{{
context:{{age_seconds:12, degraded:true}}, notifications:{{age_seconds:14, stale:true}}, events:{{age_seconds:3}}
}}}};
process.stdout.write(JSON.stringify({{
healthy:status.describe(healthy),
one:status.describe(oneDelayed),
two:status.describe(twoDelayed),
unavailable:status.describe({{}}),
}}));
"""
result = run_node(script)
assert result["healthy"]["summary"] == "Live"
assert [feed["state"] for feed in result["healthy"]["feeds"]] == ["live"] * 3
assert result["one"]["summary"] == "Updates delayed"
assert result["one"]["nextRetrySeconds"] == 30
assert result["one"]["feeds"][1] == {
"key": "notifications", "label": "Updates", "state": "delayed", "ageSeconds": 14
}
assert result["two"]["summary"] == "2 data feeds delayed"
assert result["unavailable"]["summary"] == "Live data unavailable"
def test_live_data_status_controller_is_single_flight_and_reports_result():
script = f"""
const status = require({json.dumps(str(STATUS))});
let resolveRefresh;
let calls = 0;
const states = [];
const button = {{disabled:false}};
const output = {{textContent:''}};
const controller = status.createRefreshController({{
button, output,
refresh:() => {{ calls += 1; return new Promise(resolve => {{ resolveRefresh = resolve; }}); }},
onState:value => states.push(value),
}});
(async () => {{
const first = controller.run();
const second = controller.run();
const pending = {{calls, disabled:button.disabled, text:output.textContent}};
resolveRefresh({{context:{{}}}});
await Promise.all([first, second]);
process.stdout.write(JSON.stringify({{pending, calls, disabled:button.disabled, text:output.textContent, states}}));
}})();
"""
assert run_node(script) == {
"pending": {"calls": 1, "disabled": True, "text": "Refreshing live data…"},
"calls": 1,
"disabled": False,
"text": "Live data refreshed.",
"states": ["refreshing", "success"],
}
def test_live_data_status_has_accessible_mobile_safe_sheet_contract():
html = HTML.read_text()
css = CSS.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="open-live-data-status"' in html
assert 'aria-controls="live-data-status-sheet"' in html
assert 'id="live-data-status-sheet"' in html
assert 'aria-labelledby="live-data-status-heading"' in html
assert 'id="live-data-status-feeds"' in html
assert 'id="refresh-live-data"' in html
assert '<script src="static/live-data-status.js"></script>' in html
assert ".live-data-status-panel" in css
assert "width:min(560px,100%)" in css
assert "min-height:44px" in css
assert "liveDataStatus.describe" in dashboard
assert "liveDataStatus.createRefreshController" in dashboard
assert "contextPoller.getState()" in dashboard
+38
View File
@@ -370,6 +370,44 @@ setTimeout(() => process.stdout.write(JSON.stringify({{
}
def test_login_continues_only_bounded_shared_screenshot_capture():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
async function destination(continuation) {{
let replaced = null;
const controller = createLoginController({{
form: {{ reset: () => {{}} }}, status: {{ textContent: '' }}, button: {{ disabled: false }},
fetchImpl: async () => new Response('{{}}', {{ status: 200 }}),
location: {{ replace: value => replaced = value }}, continuation,
}});
await controller.submit('operator-token');
return replaced;
}}
(async () => {{
process.stdout.write(JSON.stringify({{
valid: await destination('./?title=Broken&launch=new&shared=image'),
wrongLaunch: await destination('./?launch=agenda&shared=image'),
missingLaunch: await destination('./?shared=image'),
wrongMarker: await destination('./?launch=new&shared=document'),
duplicateMarker: await destination('./?launch=new&shared=image&shared=image'),
launchWithoutMarker: await destination('./?launch=new'),
}}));
}})().catch(error => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
assert json.loads(result.stdout) == {
"valid": "./?title=Broken&launch=new&shared=image",
"wrongLaunch": "./",
"missingLaunch": "./",
"wrongMarker": "./",
"duplicateMarker": "./",
"launchWithoutMarker": "./?launch=new",
}
@pytest.mark.anyio
async def test_login_page_loads_rate_limit_controller():
html = await login()
+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-v97" in worker
assert "stackchain-dashboard-shell-v100" 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
+1 -1
View File
@@ -45,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-v97" in worker
assert "stackchain-dashboard-shell-v100" 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-v97" in worker
assert "stackchain-dashboard-shell-v100" in worker
assert ".device-setup-panel" in css
assert ".device-readiness-card" in css
assert "overflow-x:hidden" in css
+40 -5
View File
@@ -8,16 +8,44 @@ from tests.dashboard_bundle import dashboard
MOBILE_LAUNCH = Path(__file__).resolve().parents[1] / "frontend" / "mobile-launch.js"
APP_SHORTCUTS = Path(__file__).resolve().parents[1] / "frontend" / "mobile-app-shortcuts.js"
def test_installed_app_shortcut_accepts_only_bounded_actions_and_runs_once():
script = f"""
const shortcuts = require({json.dumps(str(APP_SHORTCUTS))});
const calls = [];
const controller = shortcuts.createController({{
search:'?launch=continue',
continueWork:() => calls.push('continue'),
newIssue:() => calls.push('new'),
agenda:() => calls.push('agenda'),
}});
Promise.resolve(controller.run()).then(() => controller.run()).then(() => {{
process.stdout.write(JSON.stringify({{
action:controller.action(), calls,
invalid:shortcuts.parse('?launch=delete-account'),
shared:shortcuts.parse('?launch=new&title=Shared'),
}}));
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"action": "continue", "calls": ["continue"], "invalid": None, "shared": "new"
}
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 +54,9 @@ process.stdout.write(JSON.stringify({{
assert json.loads(result.stdout) == {
"today": "today",
"attention": "attention",
"agenda": "agenda",
"all": "all",
"saved": "review",
"saved": "agenda",
}
@@ -61,9 +90,15 @@ async def test_mobile_launch_progressively_discloses_secondary_controls():
assert 'id="work-settings-toggle"' in html
assert 'id="active-work-queue"' in html
assert '<script src="static/mobile-launch.js"></script>' in html
assert '<script src="static/mobile-app-shortcuts.js"></script>' in html
service_worker = (Path(__file__).resolve().parents[1] / "frontend" / "service-worker.js").read_text()
assert "BASE + 'static/mobile-launch.js'" in service_worker
assert "BASE + 'static/mobile-app-shortcuts.js'" in service_worker
bootstrap = (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert "mobileAppShortcuts.createController" in bootstrap
assert bootstrap.index("await load();\n await appShortcut.run();") < bootstrap.index("contextPoller.start();")
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
+209 -18
View File
@@ -13,7 +13,7 @@ QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queu
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}};
@@ -27,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());
@@ -43,8 +43,45 @@ 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"],
}
def test_mobile_work_entry_preserves_active_today_then_launches_highest_priority_queue():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const calls = [];
const state = {{active:true, today:2, resumable:true, eligible:3, next:{{name:'attention'}}}};
const entry = createEntry({{
isTodayActive: () => state.active,
isTodayResumable: () => state.resumable,
getTodayCount: () => state.today,
getEligibleCount: () => state.eligible,
queueLauncher: {{recommend: () => state.next, open: name => calls.push('queue:' + name)}},
continueToday: () => calls.push('continue'),
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
planToday: () => calls.push('plan'),
findWork: () => calls.push('find'),
}});
const modes = [entry.open()];
state.active = false; modes.push(entry.open());
state.next = {{name:'today'}}; modes.push(entry.open());
state.today = 0; state.eligible = 0; state.next = {{name:'update'}}; modes.push(entry.open());
state.next = {{name:'agenda'}}; modes.push(entry.open());
state.next = {{name:'later'}}; modes.push(entry.open());
state.next = {{name:'draft'}}; modes.push(entry.open());
state.next = {{name:'find'}}; modes.push(entry.open());
process.stdout.write(JSON.stringify({{modes, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "attention", "resume", "update", "agenda", "later", "draft", "find"],
"calls": ["continue", "queue:attention", "resume", "queue:update", "queue:agenda", "queue:later", "queue:draft", "find"],
}
@@ -165,10 +202,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(
@@ -184,7 +221,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"},
}
@@ -293,18 +330,19 @@ sheet.showModal = function () {{ this.open = true; }};
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
const close = new FakeElement();
const badge = new FakeElement();
const rows = Object.fromEntries(['today','attention','update','later','draft','recaps'].map(name => [name, new FakeElement()]));
const 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,
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, update:5, 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.update.click();
@@ -312,9 +350,26 @@ const closedAfterSelect = !sheet.open;
queues.click(); rows.recaps.click();
const closedAfterUtility = !sheet.open;
queues.click(); close.click();
const populated = {{
badge:badge.textContent, badgeHidden:badge.hidden,
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:7, later:0, draft:0}});
const updatesOnly = {{
badge:badge.textContent, badgeHidden:badge.hidden,
deadlineHidden:deadline.hidden,
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, closedAfterUtility, selected, utilities,
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
badge:badge.textContent, populated, updatesOnly,
clearedBadgeHidden:badge.hidden,
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,
@@ -330,11 +385,27 @@ process.stdout.write(JSON.stringify({{
"closedAfterUtility": True,
"selected": ["update"],
"utilities": ["recaps:trigger"],
# Updates are a drill-down within Attention and must not inflate the aggregate badge.
"badge": "10",
"badgeLabel": "Queues, 10 items",
"counts": {"today": "2", "attention": "1", "update": "5", "later": "3", "draft": "4", "recaps": "0"},
"updateLabel": "Updates, 5 unread conversations",
"badge": "0 active",
"populated": {
"badge": "5 active",
"badgeHidden": False,
"badgeLabel": "Queues: Today 2, Agenda 5 due, Attention 1, Updates 5, Later 3, Drafts 4; 5 active queues",
"deadline": "5 due",
"deadlineHidden": False,
"agendaDue": "true",
},
"updatesOnly": {
"badge": "1 active",
"badgeHidden": False,
"deadlineHidden": True,
"badgeLabel": "Queues: Today 0, Agenda 0 due, Attention 0, Updates 7, Later 0, Drafts 0; 1 active queue",
},
"clearedBadgeHidden": True,
"clearedDeadlineHidden": True,
"clearedAgendaDue": None,
"clearedBadgeLabel": "Queues: no active queues; no upcoming deadlines",
"counts": {"today": "0", "agenda": "0", "attention": "0", "update": "0", "later": "0", "draft": "0", "recaps": "0"},
"updateLabel": "Updates, 0 unread conversations",
"queueFocuses": 3,
"columnState": None,
}
@@ -368,6 +439,106 @@ process.stdout.write(JSON.stringify({{opened, empty, calls}}));
}
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'),
openAgenda: () => {{ calls.push('agenda'); return 'opened'; }},
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": ["agenda", "find"],
}
def test_mobile_queue_launcher_prioritizes_updates_before_agenda_and_resumes_launcher():
script = f"""
const createLauncher = require({json.dumps(str(QUEUE_LAUNCHER))});
const calls = [];
let counts = {{attention:0, today:0, update:6, agenda:2, later:1, draft:1}};
const launcher = createLauncher({{
getCounts: () => counts,
openUpdates: () => {{ calls.push('updates'); return 'resumed'; }},
openAgenda: () => calls.push('agenda'),
selectFilter: name => calls.push('filter:' + name),
firstAction: () => null,
announce: () => {{}},
openFindWork: () => calls.push('find'),
}});
const recommendation = launcher.recommend();
const opened = launcher.continueWork();
process.stdout.write(JSON.stringify({{recommendation, opened, calls}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"recommendation": {"name": "update", "count": 6, "label": "Resume Updates (6)"},
"opened": "resumed",
"calls": ["updates"],
}
def test_mobile_task_dock_labels_each_recommended_work_destination_accessibly():
script = f"""
const createDock = require({json.dumps(str(DOCK))});
const work = {{attributes: {{}}, setAttribute(name, value) {{ this.attributes[name] = value; }}}};
const workLabel = {{textContent:''}};
const dock = createDock({{nav:{{}}, buttons:{{work}}, workLabel}});
const labels = {{}};
for (const mode of ['attention','update','agenda','later','draft']) {{
dock.updateWork(mode);
labels[mode] = [workLabel.textContent, work.attributes['aria-label']];
}}
process.stdout.write(JSON.stringify(labels));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"attention": ["Attention", "Open Attention"],
"update": ["Updates", "Resume Updates"],
"agenda": ["Agenda", "Open Agenda"],
"later": ["Later", "Open Later"],
"draft": ["Drafts", "Open Drafts"],
}
@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()
@@ -376,6 +547,12 @@ 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
@@ -384,14 +561,17 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
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 '<script src="static/mobile-queue-launcher.js"></script>' in html
assert "const mobileQueueLauncher = createMobileQueueLauncher({" in html
assert "firstAction: name => qs('#my-work-list .my-work-card-main, #my-work-list .draft-resume, #my-work-list .draft-continue, #my-work-list .draft-edit')" in html
assert "onSelectQueue:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" in html
assert "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
@@ -417,6 +597,17 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
assert "runTodayTransition('continue')" in html
@pytest.mark.anyio
async def test_dashboard_wires_work_dock_and_app_shortcut_to_live_queue_recommendation():
html = await dashboard()
assert "queueLauncher: mobileQueueLauncher" in html
assert "work: () => mobileWorkEntry.open()" in html
assert "continueWork: () => mobileWorkEntry.open()" in html
assert "mobileQueueCounts = counts;" in html
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
@pytest.mark.anyio
async def test_dashboard_renders_and_wires_phone_safe_task_dock():
html = await dashboard()
@@ -434,7 +625,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:name => name === 'recaps' ? qs('#open-today-recaps').click() : mobileQueueLauncher.open(name)" 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
+668 -11
View File
@@ -8,6 +8,22 @@ 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"
@@ -24,11 +40,310 @@ 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"
AGENDA_SESSION_LAUNCHER = Path(__file__).parents[1] / "frontend" / "agenda-session-launcher.js"
UPDATE_TRIAGE_LAUNCHER = Path(__file__).parents[1] / "frontend" / "update-triage-launcher.js"
def test_update_triage_launch_waits_for_complete_single_flight_discovery():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
let release;
let discoveries = 0;
let opens = 0;
const messages = [];
const launcher = createLauncher({{
selectUpdates: () => {{}},
discover: () => {{ discoveries += 1; return new Promise(resolve => {{ release = resolve; }}); }},
isUpdatesSelected: () => true,
hasMore: () => false,
hasCheckpoint: () => false,
resume: () => {{ throw new Error('must start'); }},
start: () => {{ opens += 1; return true; }},
announce: message => messages.push(message),
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
const before = {{discoveries, opens, same:first === second}};
release(true);
return Promise.all([first, second]).then(results =>
process.stdout.write(JSON.stringify({{before, discoveries, opens, results, messages}}))
);
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"discoveries": 1, "opens": 0, "same": True},
"discoveries": 1,
"opens": 1,
"results": ["opened", "opened"],
"messages": ["Checking all unread updates…"],
}
def test_update_triage_launch_preserves_checkpoint_when_discovery_fails():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const messages = [];
let resumed = 0;
const launcher = createLauncher({{
selectUpdates: () => {{}},
discover: async () => false,
isUpdatesSelected: () => true,
hasMore: () => true,
hasCheckpoint: () => true,
resume: () => {{ resumed += 1; return true; }},
start: () => true,
announce: message => messages.push(message),
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, resumed, messages}})));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "incomplete",
"resumed": 0,
"messages": [
"Checking all unread updates…",
"Updates check paused. Retry to check older unread updates.",
],
}
def test_update_triage_launch_applies_atomic_snapshot_before_starting():
script = f"""
const createLauncher = require({json.dumps(str(UPDATE_TRIAGE_LAUNCHER))});
const events = [];
const snapshot = {{items:[{{id:1}},{{id:51}}],total:2,complete:true}};
const launcher = createLauncher({{
selectUpdates: () => events.push('selected'),
discover: async () => snapshot,
applySnapshot: value => events.push(['applied', value.items.map(item => item.id)]),
isUpdatesSelected: () => true,
hasMore: () => false,
hasCheckpoint: () => false,
resume: () => {{ throw new Error('must start'); }},
start: () => {{ events.push('started'); return true; }},
announce: () => {{}},
}});
launcher.open().then(result => process.stdout.write(JSON.stringify({{result, events}})));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"result": "opened",
"events": ["selected", ["applied", [1, 51]], "started"],
}
def test_notification_pager_load_all_retries_from_failed_page_without_duplicates():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let items = [{{id:1}}];
let fail = true;
const requested = [];
const pager = buildMyWork.createNotificationPager({{
load: async page => {{
requested.push(page);
if (page === 2 && fail) {{ fail = false; throw new Error('offline'); }}
return page === 2 ? {{page:2,total:101,has_more:true,items:[{{id:1}},{{id:51}}]}} :
{{page:3,total:101,has_more:false,items:[{{id:101}}]}};
}},
onNotifications: value => {{ items = value; }},
onPagination: () => {{}},
onStatus: () => {{}},
}});
pager.reset({{page:1,total:101,has_more:true}});
pager.loadAll(() => items).then(firstResult => pager.loadAll(() => items).then(secondResult =>
process.stdout.write(JSON.stringify({{firstResult, secondResult, requested, ids:items.map(item => item.id)}}))
));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"firstResult": False,
"secondResult": True,
"requested": [2, 2, 3],
"ids": [1, 51, 101],
}
def test_agenda_session_launch_waits_for_complete_single_flight_discovery():
script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
let finishDiscovery;
let discoveries = 0;
let opens = 0;
let queue = 'agenda';
const launcher = createLauncher({{
selectAgenda: () => {{ queue = 'agenda'; }},
discover: () => {{
discoveries += 1;
return new Promise(resolve => {{ finishDiscovery = resolve; }});
}},
isAgendaSelected: () => queue === 'agenda',
hasMore: () => false,
hasCheckpoint: () => true,
resume: () => {{ opens += 1; return true; }},
start: () => {{ throw new Error('must resume'); }},
announce: () => {{}},
}});
const first = launcher.open();
const second = launcher.open();
Promise.resolve().then(() => {{
const before = {{discoveries, opens, same:first === second}};
finishDiscovery(true);
return Promise.all([first, second]).then(results => {{
process.stdout.write(JSON.stringify({{before, results, discoveries, opens}}));
}});
}});
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"before": {"discoveries": 1, "opens": 0, "same": True},
"results": ["opened", "opened"],
"discoveries": 1,
"opens": 1,
}
def test_agenda_session_launch_preserves_progress_when_discovery_fails_or_queue_changes():
script = f"""
const createLauncher = require({json.dumps(str(AGENDA_SESSION_LAUNCHER))});
async function scenario(mode) {{
let queue = 'agenda';
let opens = 0;
const messages = [];
const launcher = createLauncher({{
selectAgenda: () => {{ queue = 'agenda'; }},
discover: async () => {{
if (mode === 'leave') queue = 'today';
if (mode === 'throw') throw new Error('offline');
return mode !== 'incomplete';
}},
isAgendaSelected: () => queue === 'agenda',
hasMore: () => mode === 'incomplete',
hasCheckpoint: () => true,
resume: () => {{ opens += 1; return true; }},
start: () => {{ opens += 1; return true; }},
announce: message => messages.push(message),
}});
return {{result:await launcher.open(), opens, messages}};
}}
Promise.all(['incomplete', 'throw', 'leave'].map(scenario)).then(results =>
process.stdout.write(JSON.stringify(results))
);
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
{"result": "incomplete", "opens": 0, "messages": [
"Agenda check paused. Retry to check older assigned deadlines."
]},
{"result": "incomplete", "opens": 0, "messages": [
"Agenda check paused. Retry to check older assigned deadlines."
]},
{"result": "cancelled", "opens": 0, "messages": []},
]
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))});
@@ -54,6 +369,259 @@ process.stdout.write(JSON.stringify({{
}
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"]
def test_mobile_agenda_runs_as_an_ordered_non_durable_work_session():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const checkpoint = {{
read:() => null,
save:() => {{ throw new Error('Agenda must not save Today'); }},
clear:() => {{ throw new Error('Agenda must not clear Today'); }},
}};
const active = [
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow',is_assigned:true,due_date:'2026-08-14'}},
{{kind:'issue',repository:'o/r',number:1,title:'Overdue',is_assigned:true,due_date:'2026-08-12'}},
{{kind:'issue',repository:'o/r',number:2,title:'Today',is_assigned:true,due_date:'2026-08-13'}},
{{kind:'issue',repository:'o/r',number:4,title:'No deadline',is_assigned:true}},
];
const opened = [];
let finished = 0;
const session = buildMyWork.createWorkSession({{
getItems:() => buildMyWork.agendaMyWork(active, new Date('2026-08-13T12:00:00')),
getFilter:() => 'all', checkpoint, checkpointEnabled:() => false,
onOpen:item => opened.push(item.title), onProgress:() => {{}},
onFinish:() => {{ finished += 1; }},
}});
const started = session.start();
session.next();
session.next();
const completed = session.next();
process.stdout.write(JSON.stringify({{
started, completed, opened, finished, active:session.active(), today:values.get('stackchain.today-session.v1'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"started": True,
"completed": False,
"opened": ["Overdue", "Today", "Tomorrow"],
"finished": 1,
"active": False,
"today": "preserve-today",
}
def test_mobile_agenda_checkpoint_is_account_bound_resumable_and_separate_from_today():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const values = new Map([['stackchain.today-session.v1', 'preserve-today']]);
const storage = {{
getItem:key => values.get(key) || null,
setItem:(key, value) => values.set(key, value),
removeItem:key => values.delete(key),
}};
let login = 'timmy';
const agendaCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.agenda-session.v1',
}});
const todayCheckpoint = buildMyWork.createWorkSessionCheckpoint({{
storage, getLogin:() => login, key:'stackchain.today-session.v1',
}});
let mode = 'agenda';
let items = [
{{kind:'issue',repository:'o/r',number:1,title:'Overdue'}},
{{kind:'issue',repository:'o/r',number:2,title:'Today'}},
{{kind:'issue',repository:'o/r',number:3,title:'Tomorrow'}},
];
const opened = [];
const makeSession = () => buildMyWork.createWorkSession({{
getItems:() => items, getFilter:() => 'all',
checkpoint:() => mode === 'agenda' ? agendaCheckpoint : todayCheckpoint,
checkpointEnabled:() => true, checkpointedEnabled:() => mode === 'today',
onOpen:item => opened.push(item.title), onProgress:() => {{}}, onFinish:() => {{}},
}});
const first = makeSession();
first.start();
first.next();
const saved = JSON.parse(values.get('stackchain.agenda-session.v1'));
const todayWhileRunning = first.checkpointed();
login = 'alexander';
const hiddenFromOtherAccount = makeSession().resumable();
login = 'timmy';
items = items.filter(item => item.number !== 2);
const resumedSession = makeSession();
const resumed = resumedSession.resume();
resumedSession.end();
process.stdout.write(JSON.stringify({{
saved, todayWhileRunning, hiddenFromOtherAccount, resumed, opened,
agendaCleared:!values.has('stackchain.agenda-session.v1'),
today:values.get('stackchain.today-session.v1'),
}}));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state["saved"] == {
"version": 1, "login": "timmy", "identity": "issue:o/r:2:", "index": 1,
}
assert state["todayWhileRunning"] is False
assert state["hiddenFromOtherAccount"] is False
assert state["resumed"] is True
assert state["opened"] == ["Overdue", "Today", "Tomorrow"]
assert state["agendaCleared"] is True
assert state["today"] == "preserve-today"
@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_or_resumes_the_durable_agenda_session():
html = await dashboard()
assert '<script src="static/agenda-session-launcher.js"></script>' in html
assert "openAgenda: openAgendaSession" in html
assert "const agendaSessionLauncher = createAgendaSessionLauncher({" in html
assert "discover: completeAgendaIssues" in html
assert "hasMore: () => Boolean(workPagination.issue?.has_more)" in html
assert "key: 'stackchain.agenda-session.v1'" in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "window.location.hash = '#/my-work/agenda'" in html
@pytest.mark.anyio
async def test_mobile_agenda_launcher_starts_the_rendered_queue_and_finishes_in_agenda():
html = await dashboard()
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork)" in html
assert "selectedWorkFilter === 'agenda' ? 'all'" in html
assert "agendaSessionCheckpoint.read() ? 'Resume Agenda' : 'Start Agenda'" in html
assert "const sessionItems = workSession.items()" in html
assert "qs('#my-work-action-status').textContent = 'Agenda complete.'" in html
assert "checkpointEnabled: () => ['today', 'agenda'].includes(selectedWorkFilter)" in html
@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()
@@ -540,12 +1108,12 @@ process.stdout.write(JSON.stringify({{calls, hash:location.hash, parsed:routes.p
def test_every_mobile_queue_route_survives_hydration_and_detail_back_navigation():
script = f"""
const routes = require({json.dumps(str(WORK_ROUTE))});
const canonical = ['today', 'attention', 'updates', 'later', 'drafts'];
const 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/later'}};
const location = {{hash:'#/my-work/agenda'}};
const calls = [];
const stack = ['#/my-work/later'];
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; }},
@@ -574,16 +1142,17 @@ process.stdout.write(JSON.stringify({{parsed, calls, hash:location.hash}}));
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", "later"],
["queue", "agenda"],
["open", 42],
["close"],
["queue", "later"],
["queue", "agenda"],
["queue", "draft"],
],
"hash": "#/my-work/drafts",
@@ -1254,7 +1823,7 @@ async def test_mobile_my_work_exposes_touch_safe_milestone_lane_and_issue_editor
assert 'id="save-issue-milestone"' in html
assert 'class="pill milestone-badge"' in html
assert '.work-milestone-filter, .issue-milestone-editor select, .issue-milestone-editor button { min-height:44px;' in html
assert 'getMilestone: () => selectedWorkMilestone' in html
assert "getMilestone: () => selectedWorkFilter === 'agenda' ? 'all' : selectedWorkMilestone" in html
assert 'buildMyWork.replaceIssueMilestone(' in html
@@ -2943,8 +3512,8 @@ async def test_dashboard_offers_account_safe_resume_and_end_today_controls():
assert 'id="end-today-session"' in html
assert 'const sessionCheckpoint = createWorkSessionCheckpoint({' in html
assert 'getLogin: () => confirmedOwnerLogin' in html
assert 'checkpoint: sessionCheckpoint' in html
assert 'checkpointEnabled: () => selectedWorkFilter === \'today\'' in html
assert "checkpoint: () => selectedWorkFilter === 'agenda' ? agendaSessionCheckpoint : sessionCheckpoint" in html
assert "checkpointedEnabled: () => selectedWorkFilter === 'today'" in html
assert "qs('#resume-today-session').addEventListener('click'" in html
assert "qs('#end-today-session').addEventListener('click'" in html
assert "workSession.resume(item)" in html
@@ -3281,7 +3850,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}
@@ -4299,6 +4871,76 @@ reader.open(original[0], original).then(() =>
assert output["result"]["next"]["notification_id"] == 43
def test_notification_reader_consumes_one_bounded_prefetch_without_duplicate_load():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
const items = [1,2,3].map(notification_id => ({{kind:'update', notification_id, has_update:true}}));
const loaded = [], details = [], statuses = [];
const releases = {{}};
const reader = buildMyWork.createNotificationReader({{
load: id => {{
loaded.push(id);
if (id === 1) return Promise.resolve({{id}});
return new Promise(resolve => {{ releases[id] = resolve; }});
}},
markRead: async () => {{}},
onOpen: () => {{}}, onDetail: detail => details.push(detail.id), onItems: () => {{}},
onStatus: status => statuses.push(status), onClose: () => {{}},
}});
(async () => {{
await reader.open(items[0]);
const first = reader.prefetch(items[1]);
const duplicate = reader.prefetch(items[1]);
const rejected = reader.prefetch(items[2]);
const advancing = reader.open(items[1]);
await Promise.resolve();
releases[2]({{id:2}});
await advancing;
process.stdout.write(JSON.stringify({{
loaded, details, same:first === duplicate, rejected, statuses,
}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {
"loaded": [1, 2],
"details": [1, 2],
"same": True,
"rejected": False,
"statuses": ["Loading update…", "Update ready.", "Update ready."],
}
def test_notification_reader_never_consumes_prefetch_from_another_account_scope():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
let scope = 'timmy';
const loaded = [];
const reader = buildMyWork.createNotificationReader({{
load: async id => {{ loaded.push([scope, id]); return {{owner:scope, id}}; }},
getScope: () => scope,
markRead: async () => {{}}, onOpen: () => {{}}, onItems: () => {{}}, onStatus: () => {{}},
onDetail: detail => {{ if (detail.owner !== scope) throw new Error('cross-account detail'); }},
onClose: () => {{}},
}});
(async () => {{
await reader.open({{notification_id:1}});
await reader.prefetch({{notification_id:2}});
scope = 'alexander';
const opened = await reader.open({{notification_id:2}});
process.stdout.write(JSON.stringify({{loaded, opened}}));
}})();
"""
output = json.loads(subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout)
assert output == {"loaded": [["timmy", 1], ["timmy", 2], ["alexander", 2]], "opened": True}
def test_notification_reader_acknowledges_once_and_opens_next_update():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -4413,6 +5055,21 @@ async def test_mobile_update_sheet_wires_touch_safe_online_acknowledge_and_next(
assert '.update-sheet-actions #acknowledge-update-next { min-height:44px;' in html
@pytest.mark.anyio
async def test_mobile_update_sheet_mutes_future_updates_and_advances_only_on_read_success():
html = await dashboard()
assert 'id="mute-update-next" type="button" hidden' in html
assert ">Mute future updates &amp; next</button>" in html
assert "qs('#mute-update-next').hidden = !detail.mute_supported;" in html
assert "async function muteNotification(notificationId)" in html
assert "'/mute', { method: 'POST'" in html
assert "notificationReader.acceptReadAndNext(lastMyWork, item)" in html
assert "Future updates are muted; current item is still unread" in html
assert "qs('#mute-update-next').disabled = offline;" in html
assert ".update-sheet-actions #mute-update-next" in html
def test_notification_reader_keeps_current_update_retryable_when_detail_load_fails():
script = f"""
const buildMyWork = require({json.dumps(str(MY_WORK))});
@@ -5597,7 +6254,7 @@ async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels(
assert '<script src="static/my-work.js"></script>' in html
assert "buildMyWork(data)" in html
assert "markMyWorkStale()" in html
assert "filterMyWork(lastMyWork, selectedWorkFilter)" in html
assert "const sessionItems = workSession.items()" in html
@pytest.mark.anyio
@@ -5794,7 +6451,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
+52
View File
@@ -31,3 +31,55 @@ async def test_notification_page_endpoint_loads_only_requested_page_and_is_not_c
"has_more": True,
}
assert requested == [2]
@pytest.mark.anyio
async def test_notification_snapshot_endpoint_returns_one_complete_atomic_no_store_result(monkeypatch):
requested = []
async def snapshot_loader(*, deadline_seconds):
requested.append(deadline_seconds)
return {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"items": [
{"id": 1, "title": "Newest update"},
{"id": 51, "title": "Older update"},
],
"total": 2,
"complete": True,
}
assert requested == [main.NOTIFICATION_PAGE_TIMEOUT_SECONDS]
@pytest.mark.anyio
@pytest.mark.parametrize("failure", [TimeoutError(), ValueError("pagination changed")])
async def test_notification_snapshot_endpoint_is_bounded_and_retryable(monkeypatch, failure):
async def snapshot_loader(*, deadline_seconds):
raise failure
monkeypatch.setattr(main.gitea_proxy, "unread_notification_snapshot", snapshot_loader)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/notifications/snapshot")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.headers["retry-after"] == "1"
assert response.json() == {
"error": "Complete unread updates are temporarily unavailable. Please retry."
}
+28
View File
@@ -215,6 +215,34 @@ async def test_mark_notification_unread_api_is_bounded_and_never_cacheable(monke
assert restored == [42]
@pytest.mark.anyio
async def test_mute_notification_then_marks_read_and_reports_partial_success(monkeypatch):
calls = []
async def mute(thread_id):
calls.append(("mute", thread_id))
return {"id": thread_id, "muted": True}
async def fail_read(thread_id):
calls.append(("read", thread_id))
raise httpx.HTTPError("unavailable")
monkeypatch.setattr(main.gitea_proxy, "mute_notification", mute, raising=False)
monkeypatch.setattr(main, "mark_notification_read", fail_read)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/api/v1/notifications/42/mute")
assert response.status_code == 409
assert response.json() == {
"id": 42,
"muted": True,
"status": "unread",
"error": "Future updates are muted; current item is still unread. Retry mark read & next.",
}
assert calls == [("mute", 42), ("read", 42)]
@pytest.mark.anyio
async def test_acknowledge_notification_api_confirms_reaction_and_removes_snapshot_item(monkeypatch):
calls = []
+1 -1
View File
@@ -410,7 +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-v97" in source
assert "stackchain-dashboard-shell-v100" 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
+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
+107 -1
View File
@@ -14,6 +14,13 @@ 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 deadlineDays = {value:'2', disabled:false, addEventListener:(_name, callback) => state.deadlineDaysChange = 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 +28,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, deadlineDays,
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 +64,100 @@ 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, next 2 days."
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, next 2 days."
def test_deadline_setup_persists_and_restores_confirmed_horizon():
saved = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:9,reminder_days:2,public_key:'AQID'};
state.current = existing;
await feature.init();
deadlineDays.value = '7';
await feature.changeDeadline();
process.stdout.write(JSON.stringify(state));
""")
assert json.loads(saved["requests"][-1][2])["reminder_days"] == 7
assert saved["deadlineText"] == "Deadline reminders enabled for 09:00 local time, next 7 days."
restored = run_scenario("""
state.server = {available:true,subscribed:true,deadline_enabled:true,reminder_hour:17,reminder_days:0,public_key:'AQID'};
state.current = existing;
await feature.init();
process.stdout.write(JSON.stringify({days:deadlineDays.value, readiness:feature.deadlineReadiness()}));
""")
assert restored == {
"days": "0",
"readiness": {
"state": "complete",
"detail": "Deadline reminders enabled for 17:00 local time, due today.",
},
}
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, next 2 days."},
}
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 +169,11 @@ 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-days"' in html
assert 'id="device-setup-deadline-days"' 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
+196 -7
View File
@@ -204,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 = {}
@@ -225,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 = {
@@ -751,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
@@ -778,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):
@@ -786,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 == []
@@ -819,7 +974,13 @@ 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,
"reminder_days": 2,
}
assert await main.subscribe_push(payload, request) == {"subscribed": True}
assert (await main.push_status(request))["subscribed"] is True
@@ -827,6 +988,34 @@ 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, reminder_days=7
)
assert await main.update_deadline_reminders(payload, request) == {
"deadline_enabled": True,
"timezone": "America/New_York",
"reminder_hour": 9,
"reminder_days": 7,
}
assert store.deadline_preferences("session-a")["enabled"] is True
assert store.deadline_preferences("session-a")["reminder_days"] == 7
@pytest.mark.anyio
async def test_subscription_rejects_an_unsafe_endpoint_before_persistence(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
+127 -11
View File
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], sharedRecords: {{}}, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -46,6 +46,11 @@ const context = {{
location: {{ href: 'https://forge.example/dashboard/service-worker.js', origin: 'https://forge.example' }},
__STACKCHAIN_NAVIGATION_TIMEOUT_MS: 15,
__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS: 15,
__STACKCHAIN_SHARED_ATTACHMENT_STORE: {{
put: async (id, value) => {{ state.sharedRecords[id] = value; }},
get: async id => state.sharedRecords[id] || null,
delete: async id => {{ delete state.sharedRecords[id]; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
clients: {{
@@ -145,7 +150,7 @@ async function dispatchPush(payload) {{
def test_resumable_today_session_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v97" in source
assert "stackchain-dashboard-shell-v100" 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 +159,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-v97" in source
assert "stackchain-dashboard-shell-v100" 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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/today-completion.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -169,7 +174,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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/create-issue-sheet.js'" in source
assert "BASE + 'static/dashboard.js'" in source
@@ -177,14 +182,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-v97" in source
assert "stackchain-dashboard-shell-v100" 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-v97" in source
assert "stackchain-dashboard-shell-v100" 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,24 +198,77 @@ 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-v97" in source
assert "stackchain-dashboard-shell-v100" 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-v97" in source
assert "stackchain-dashboard-shell-v100" 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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/update-ownership.js'" in source
def test_image_share_target_stages_one_supported_image_and_redirects_to_capture():
result = run_worker_scenario(
"""
const image = new Blob(['pixels'], {type:'image/png'});
Object.defineProperty(image, 'name', {value:'bug.png'});
const form = new FormData();
form.append('title', 'Broken checkout');
form.append('text', 'Steps to reproduce');
form.append('url', 'https://example.test/checkout');
form.append('image', image, 'bug.png');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
const [id, record] = Object.entries(state.sharedRecords)[0];
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), id,
filename:record.filename, contentType:record.contentType, size:record.blob.size,
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=image&title=Broken+checkout&text=Steps+to+reproduce&url=https%3A%2F%2Fexample.test%2Fcheckout",
"id": "shared-image",
"filename": "bug.png",
"contentType": "image/png",
"size": 6,
}
def test_image_share_target_rejects_multiple_images_without_staging_private_data():
result = run_worker_scenario(
"""
const form = new FormData();
form.append('image', new Blob(['one'], {type:'image/png'}), 'one.png');
form.append('image', new Blob(['two'], {type:'image/jpeg'}), 'two.jpg');
const request = new Request('https://forge.example/dashboard/', {method:'POST', body:form});
Object.defineProperty(request, 'mode', {value:'navigate'});
const response = await dispatch('fetch', request);
process.stdout.write(JSON.stringify({
status:response.status, location:response.headers.get('Location'), records:Object.keys(state.sharedRecords),
}));
"""
)
assert result == {
"status": 303,
"location": "/dashboard/?launch=new&shared=multiple",
"records": [],
}
def test_background_sync_event_flushes_closed_app_issue_outbox_only_for_its_tag():
result = run_worker_scenario(
"""
@@ -459,6 +517,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 +761,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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/queue-today.js'" in source
@@ -684,6 +791,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/widgets.js",
"/dashboard/static/drafts.js",
"/dashboard/static/unfiled-captures.js",
"/dashboard/static/shared-image-capture.js",
"/dashboard/static/draft-filing-session.js",
"/dashboard/static/draft-capacity-dialog.js",
"/dashboard/static/outbox-coordinator.js",
@@ -695,6 +803,8 @@ 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",
@@ -718,6 +828,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/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",
@@ -731,10 +842,15 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/work-route.js",
"/dashboard/static/task-overlay-history.js",
"/dashboard/static/context-poller.js",
"/dashboard/static/live-data-status.js",
"/dashboard/static/mobile-task-dock.js",
"/dashboard/static/mobile-work-entry.js",
"/dashboard/static/mobile-queue-launcher.js",
"/dashboard/static/update-triage-session.js",
"/dashboard/static/update-triage-launcher.js",
"/dashboard/static/agenda-session-launcher.js",
"/dashboard/static/mobile-launch.js",
"/dashboard/static/mobile-app-shortcuts.js",
"/dashboard/static/install-app.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.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])
+54
View File
@@ -0,0 +1,54 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).resolve().parents[1] / "frontend" / "shared-image-capture.js"
def test_shared_image_is_consumed_once_and_restored_into_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
const calls=[];
const store={{
get:async id=>({{filename:'bug.png',contentType:'image/png',blob:{{size:6,type:'image/png'}}}}),
delete:async id=>calls.push(['delete',id]),
}};
shared.consume({{
marker:'image', store,
restore:value=>calls.push(['restore',value.filename]),
status:message=>calls.push(['status',message]),
}}).then(first=>shared.consume({{marker:'',store,restore:()=>calls.push(['restore-again'])}}).then(second=>{{
process.stdout.write(JSON.stringify({{first,second,calls}}));
}}));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"first": True,
"second": False,
"calls": [
["restore", "bug.png"],
["delete", "shared-image"],
["status", "Shared screenshot ready to file with this issue."],
],
}
def test_invalid_shared_image_reports_error_without_touching_open_capture():
script = f"""
const shared = require({json.dumps(str(MODULE))});
const calls=[];
shared.consume({{
marker:'unsupported', store:{{get:async()=>null,delete:async()=>calls.push('delete')}},
restore:()=>calls.push('restore'), status:message=>calls.push(message),
}}).then(result=>process.stdout.write(JSON.stringify({{result,calls}})));
"""
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
assert json.loads(completed.stdout) == {
"result": False,
"calls": ["Share one PNG, JPEG, or WebP screenshot."],
}
+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-v97';" in service_worker
assert "const CACHE = 'stackchain-dashboard-shell-v100';" 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-v97" in source
assert "stackchain-dashboard-shell-v100" in source
assert "BASE + 'static/today-sync.js'" in source
+2 -1
View File
@@ -344,7 +344,8 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
assert "const todayWork = createTodayWork({" in html
assert "getTodayCount: () => todayMyWork.length" in html
assert "startToday: startTodaySession" in html
assert "selectedWorkFilter === 'today' ? todayMyWork : activeMyWork" in html
assert "selectedWorkFilter === 'today' ? todayMyWork :" in html
assert "selectedWorkFilter === 'agenda' ? agendaMyWork(activeMyWork) : activeMyWork" in html
assert 'data-today-add' in html
assert 'data-today-remove' in html
assert 'data-today-move="up"' in html
+116
View File
@@ -0,0 +1,116 @@
import json
import subprocess
from pathlib import Path
import pytest
from tests.dashboard_bundle import dashboard
SESSION = Path(__file__).resolve().parents[1] / "frontend" / "update-triage-session.js"
def run_session(script):
source = f"const createSession = require({json.dumps(str(SESSION))});\n" + script
result = subprocess.run(["node", "-e", source], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_update_triage_persists_account_bound_pass_and_resumes_by_identity():
result = run_session("""
const values = new Map();
let login = 'timmy';
let items = [1,2,3].map(id => ({notification_id:id,title:'Update '+id}));
const opened = [], progress = [];
const options = {
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=>login, getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
};
let session = createSession(options);
session.start();
session.keepUnreadAndNext();
const saved = JSON.parse(values.get('stackchain.update-triage.v1'));
session = createSession(options);
const resumable = session.resumable();
session.resume();
login = 'other';
const isolated = session.resumable();
process.stdout.write(JSON.stringify({opened, progress, saved, resumable, isolated}));
""")
assert result["opened"] == [1, 2, 2]
assert result["progress"] == [
{"index": 1, "total": 3},
{"index": 2, "total": 3},
{"index": 2, "total": 3},
]
assert result["saved"] == {
"version": 1,
"login": "timmy",
"identities": ["1", "2", "3"],
"current": "2",
"completed": ["1"],
}
assert result["resumable"] is True
assert result["isolated"] is False
def test_update_triage_reconciles_removed_items_and_does_not_admit_new_arrivals():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const opened = [], progress = [];
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:item=>opened.push(item.notification_id), onProgress:value=>progress.push(value), onFinish:()=>opened.push('done'),
});
session.start();
items = [1,3,4].map(notification_id => ({notification_id}));
session.completeAndNext();
session.completeAndNext();
process.stdout.write(JSON.stringify({opened, progress, stored:values.get('stackchain.update-triage.v1') || null}));
""")
assert result == {
"opened": [1, 3, "done"],
"progress": [{"index": 1, "total": 3}, {"index": 3, "total": 3}],
"stored": None,
}
def test_update_triage_exposes_only_the_next_surviving_snapshot_item():
result = run_session("""
const values = new Map();
let items = [1,2,3].map(notification_id => ({notification_id}));
const session = createSession({
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)},
getLogin:()=> 'timmy', getItems:()=>items,
onOpen:()=>{}, onProgress:()=>{}, onFinish:()=>{},
});
session.start();
const first = session.next()?.notification_id;
items = [1,3,4].map(notification_id => ({notification_id}));
const afterRemoval = session.next()?.notification_id;
session.keepUnreadAndNext();
const afterAdvance = session.next();
process.stdout.write(JSON.stringify({first, afterRemoval, afterAdvance:afterAdvance || null}));
""")
assert result == {"first": 2, "afterRemoval": 3, "afterAdvance": None}
@pytest.mark.anyio
async def test_dashboard_wires_resumable_updates_triage_mobile_flow():
html = await dashboard()
assert '<script src="static/update-triage-session.js"></script>' in html
assert 'id="update-triage-progress"' in html
assert 'id="keep-update-unread" type="button">Keep unread &amp; next</button>' in html
assert "openUpdates: openUpdateTriage" in html
assert "updateTriage.acceptCompleted()" in html
assert "updateTriage.keepUnreadAndNext()" in html
assert "updateTriage.reconcile()" in html
assert ".update-triage-progress" in html
+28 -2
View File
@@ -79,15 +79,41 @@ async def test_pwa_assets_expose_root_scoped_share_target_without_caching_api_da
assert manifest.json()["start_url"] == "./"
assert manifest.json()["scope"] == "./"
assert manifest.json()["share_target"] == {
"action": "./", "method": "GET", "enctype": "application/x-www-form-urlencoded",
"params": {"title": "title", "text": "text", "url": "url"},
"action": "./", "method": "POST", "enctype": "multipart/form-data",
"params": {
"title": "title", "text": "text", "url": "url",
"files": [{"name": "image", "accept": ["image/png", "image/jpeg", "image/webp"]}],
},
}
assert manifest.json()["shortcuts"] == [
{
"name": "Continue work", "short_name": "Continue",
"description": "Resume the highest-priority mobile work flow.",
"url": "./?launch=continue",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
{
"name": "New issue", "short_name": "New",
"description": "Capture work now and file it online or offline.",
"url": "./?launch=new",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
{
"name": "Open Agenda", "short_name": "Agenda",
"description": "Check every assigned deadline and continue the Agenda session.",
"url": "./?launch=agenda",
"icons": [{"src": "static/icons/stackchain-192.png", "sizes": "192x192", "type": "image/png"}],
},
]
assert worker.status_code == 200
assert worker.headers["content-type"].startswith("application/javascript")
assert worker.headers["service-worker-allowed"] == "/"
assert "request.url.includes('/api/')" in worker.text
assert "request.method !== 'GET'" in worker.text
assert "new URL('./', self.location.href).pathname" in worker.text
assert "acceptSharedContent(request)" in worker.text
assert "sharedImageCapture.consume" in (Path(__file__).resolve().parents[1] / "frontend" / "dashboard.js").read_text()
assert '<script src="static/shared-image-capture.js"></script>' in (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
@pytest.mark.anyio